Next.js 14 App Router: layouts, pages, server components, routing, and data fetching
Defines a route segment UI. Only page.tsx makes a route publicly accessible.
// app/about/page.tsx
export default function AboutPage() {
return <h1>About</h1>;
}Shared UI wrapping page and children. Persists across navigations.
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="container">{children}</div>;
}Automatic loading UI shown while a page segment is loading
export default function Loading() {
return <div>Loading...</div>;
}Error boundary UI for a route segment. Must be a Client Component.
"use client"
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <button onClick={reset}>Try again</button>;
}UI rendered when notFound() is called or a URL has no match
export default function NotFound() {
return <h2>404 - Page Not Found</h2>;
}Renders on the server. Can fetch data directly. No hooks or browser APIs.
// No "use client" directive needed
export default async function Page() {
const data = await fetch("https://api.example.com/data");
const json = await data.json();
return <div>{json.title}</div>;
}Runs in the browser. Can use hooks, event listeners, and browser APIs.
"use client"
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}Default: cached indefinitely (static). force-dynamic disables cache.
const res = await fetch("https://api.example.com/posts", {
cache: "force-cache", // static (default)
// cache: "no-store", // dynamic, no cache
// next: { revalidate: 60 } // ISR: revalidate every 60s
});Fetch multiple resources in parallel to avoid waterfalls
const [users, posts] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
]);Async functions that run on the server, callable from Client Components
"use server"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/posts");
}Folder in brackets creates a dynamic route parameter
// app/posts/[id]/page.tsx
export default function PostPage({ params }: { params: { id: string } }) {
return <h1>Post {params.id}</h1>;
}Match multiple path segments with [...slug]
// app/docs/[...slug]/page.tsx
export default function Doc({ params }: { params: { slug: string[] } }) {
return <div>{params.slug.join("/")}</div>;
}Pre-generate static paths for dynamic routes at build time
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(p => ({ id: String(p.id) }));
}Export a metadata object from any layout or page
export const metadata: Metadata = {
title: "My App",
description: "Welcome to My App",
openGraph: { title: "My App", images: ["/og.png"] },
};Generate metadata based on route params
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const post = await getPost(params.id);
return { title: post.title };
}Create API endpoints with app/api/route.ts
// app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello" });
}
export async function POST(request: Request) {
const body = await request.json();
return NextResponse.json({ received: body }, { status: 201 });
}Data fetching in a Server Component — no useState or useEffect needed
// app/users/page.tsx
interface User { id: number; name: string; email: string }
export default async function UsersPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/users", {
next: { revalidate: 3600 }
});
const users: User[] = await res.json();
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name} — {u.email}</li>
))}
</ul>
);
}Dynamic page title and OG tags based on route params
import type { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
}Typed route handler with request body parsing
// app/api/posts/[id]/route.ts
import { NextResponse } from "next/server";
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: Number(params.id) } });
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(post);
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
await db.post.delete({ where: { id: Number(params.id) } });
return new NextResponse(null, { status: 204 });
}Default to Server Components — add 'use client' only when you need interactivity or browser APIs
Colocate data fetching as close to the component that needs it as possible
Use next: { revalidate: N } for ISR instead of disabling cache entirely
Use loading.tsx to provide instant loading UI without extra boilerplate
Wrap Client Components at the leaf of the tree to keep as much code on the server as possible