Next.js Deep Dive
Routing, rendering, data, mutations, caching, components — every common pattern with a working example.
File-system routing where folders define URL segments and special files (page, layout, loading, error) define UI.
app/
layout.tsx // root layout, wraps everything
page.tsx // /
blog/
page.tsx // /blog
[slug]/
page.tsx // /blog/:slugSquare brackets in folder names create dynamic segments. Params arrive as a Promise.
// app/blog/[slug]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <h1>{slug}</h1>;
}[...slug] matches any number of segments. [[...slug]] also matches the parent route.
app/docs/[...slug]/page.tsx // /docs/a, /docs/a/b, /docs/a/b/c app/shop/[[...path]]/page.tsx // matches /shop and /shop/anything
Folders wrapped in parentheses don't appear in the URL. Useful for organizing layouts.
app/
(marketing)/
layout.tsx // marketing layout
page.tsx // /
pricing/page.tsx // /pricing
(app)/
layout.tsx // app shell with auth
dashboard/page.tsx // /dashboard@slot folders render multiple pages into the same layout simultaneously.
app/
layout.tsx // receives { children, analytics, team }
@analytics/page.tsx
@team/page.tsx
page.tsx // children(.) intercepts a route from the same level so you can show it as a modal while preserving the URL.
app/ feed/page.tsx feed/(.)photo/[id]/page.tsx // shown as modal over /feed photo/[id]/page.tsx // standalone page
<Link> from next/link does client-side navigation with automatic prefetching.
import Link from "next/link"; <Link href="/blog/hello" prefetch>Hello</Link>
Programmatic navigation in client components.
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/dashboard");
router.refresh(); // re-fetch server components on this routeThrow to redirect or 404 from server components, server actions or route handlers.
import { redirect, notFound } from "next/navigation";
if (!user) notFound();
if (!user.verified) redirect("/verify");Components are server components by default. They run on the server, can fetch data directly, and have zero JS shipped.
// app/users/page.tsx — no "use client", runs on the server
async function UsersPage() {
const users = await db.user.findMany();
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Add 'use client' at the top of a file to mark it (and everything it imports) as client-side. Required for hooks, event handlers, browser APIs.
"use client";
import { useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}Pages render at build time when they have no dynamic dependencies. Cached at the edge.
// no dynamic APIs used → static at build time
export default function About() {
return <h1>About us</h1>;
}Triggered automatically when you use cookies(), headers(), searchParams or fetch with no-store.
import { cookies } from "next/headers";
export default async function Page() {
const c = await cookies();
const theme = c.get("theme");
return <div data-theme={theme?.value}>...</div>;
}Wrap slow parts in <Suspense>. The shell streams immediately, slow data fills in as it resolves.
import { Suspense } from "react";
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>Special file that wraps a route in <Suspense> automatically. Renders instantly while data loads.
// app/blog/loading.tsx
export default function Loading() {
return <div>Loading…</div>;
}Catches errors in the segment. Must be a client component.
"use client";
export default function Error({ error, reset }: {
error: Error;
reset: () => void;
}) {
return (
<div>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
);
}Use async/await directly. fetch() is automatically deduplicated and cached.
// app/posts/page.tsx
async function getPosts() {
const res = await fetch("https://api.example.com/posts");
return res.json();
}
export default async function Page() {
const posts = await getPosts();
return <List posts={posts} />;
}Control caching per-request via fetch options.
// always fresh
fetch(url, { cache: "no-store" });
// revalidate every 60 seconds
fetch(url, { next: { revalidate: 60 } });
// tag for on-demand revalidation
fetch(url, { next: { tags: ["posts"] } });Use Promise.all to start requests in parallel instead of waterfalling.
const [user, posts] = await Promise.all([ getUser(id), getPosts(id), ]);
Start a fetch as a side-effect so child components can await it later without a waterfall.
function preload(id: string) {
void getUser(id);
}
export default async function Page({ id }) {
preload(id);
// ... other work
const user = await getUser(id);
return <Profile user={user} />;
}Functions marked 'use server' run on the server but can be called from client components like normal functions.
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title");
await db.post.create({ data: { title } });
}
// in a client form
<form action={createPost}>
<input name="title" />
<button>Save</button>
</form>React 19 hook for tracking form action state, errors, and pending status.
"use client";
import { useActionState } from "react";
const [state, action, pending] = useActionState(createPost, { error: null });
<form action={action}>
<input name="title" />
<button disabled={pending}>Save</button>
{state.error && <p>{state.error}</p>}
</form>Invalidate cached data after a mutation so the next request fetches fresh.
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/blog"); // invalidate a route
revalidateTag("posts", "max"); // invalidate a tagfetch() responses are cached on the server, deduplicated per render. Persistent across requests until invalidated.
// cached forever, until revalidated
fetch("/api/posts");
// time-based revalidation
fetch("/api/posts", { next: { revalidate: 3600 } });Client-side cache of RSC payloads for visited routes. Makes back/forward instant.
// invalidate the router cache
import { useRouter } from "next/navigation";
router.refresh();Export config from a page or layout to control caching behavior at the route level.
export const dynamic = "force-dynamic"; export const revalidate = 60; export const fetchCache = "default-no-store"; export const runtime = "edge";
next/image automatically optimizes, lazy-loads and serves responsive images.
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>Self-host any Google or local font with zero layout shift.
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function Layout({ children }) {
return <html className={inter.className}>{children}</html>;
}Load third-party scripts with explicit loading strategy.
import Script from "next/script"; <Script src="https://analytics.example.com" strategy="afterInteractive" /> // strategies: beforeInteractive | afterInteractive | lazyOnload | worker
Export a metadata object or generateMetadata function from any page or layout.
export const metadata = {
title: "DEVBOX",
description: "Tools for developers",
openGraph: {
images: ["/og.png"],
},
};
// or dynamic
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return { title: post.title };
}Specific filenames have special meaning in the App Router.
page.tsx // route UI layout.tsx // wraps children, persists across navigations loading.tsx // suspense boundary error.tsx // error boundary not-found.tsx // 404 UI template.tsx // like layout but re-mounts on navigation default.tsx // fallback for parallel routes route.ts // API route handler middleware.ts // intercepts requests (deprecated → use proxy.ts)
Globally available types — generated during dev/build — that infer params and named slots.
export default async function Page(props: PageProps<"/blog/[slug]">) {
const { slug } = await props.params;
return <h1>{slug}</h1>;
}Layouts in folder hierarchy automatically nest. State and rendering persist on navigation.
app/layout.tsx // wraps everything app/blog/layout.tsx // wraps blog routes only app/blog/[slug]/page.tsx // child of blog layout
Export named functions per HTTP method from a route.ts file.
// app/api/users/route.ts
export async function GET(request: Request) {
const users = await db.user.findMany();
return Response.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
// ...
return Response.json({ ok: true }, { status: 201 });
}params arrive as a Promise (since Next 15+).
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
return Response.json(user);
}Server APIs to read request cookies and headers.
import { cookies, headers } from "next/headers";
const c = await cookies();
const token = c.get("token");
const h = await headers();
const ip = h.get("x-forwarded-for");Static shell + streamed dynamic islands in the same response. Best of both worlds.
// app/layout.tsx export const experimental_ppr = true; // the static shell renders instantly // <Suspense> boundaries stream dynamic content
Run code at the edge for global low-latency. Limited to Web APIs (no Node.js APIs).
// route segment config
export const runtime = "edge";
// or for middleware/proxy
export const config = { runtime: "edge" };Use priority for above-the-fold images and sizes for responsive loading.
<Image src="/hero.jpg" alt="Hero" fill priority sizes="(max-width: 768px) 100vw, 50vw" />
Vars in .env are server-only by default. Prefix with NEXT_PUBLIC_ to expose to the browser.
# .env DATABASE_URL=postgres://... # server only NEXT_PUBLIC_APP_URL=https://example.com # available in client # in code process.env.DATABASE_URL // server process.env.NEXT_PUBLIC_APP_URL // both
Configure build output: standalone (Docker), export (static HTML), or default (serverful).
// next.config.ts
const config = {
output: "standalone", // or "export"
};
export default config;