Cookbook · Next.js · 2026
The App Router recipes you reach for on every real Next.js 15 project — load data in a Server Component, submit a form with a Server Action, gate routes in middleware, read cookies on the server, validate a Route Handler. Coming from PHP/jQuery: think of the server as where your work belongs, and the browser as the thin interactive layer on top.
app/ is a Server Component by default — it runs on the server, can be async, can read cookies, and ships zero JS to the browser. You only add "use client" when you need interactivity (state, effects, event handlers). Coming from PHP this should feel natural: it's server-rendered HTML with islands of jQuery-like behaviour, except the islands are React. Stop reaching for useEffect to fetch data — that's the old SPA habit. Fetch on the server.
fetch() was cached by default (and GET Route Handlers were static). As of Next 15 nothing is cached unless you opt in. A bare fetch(url) in a Server Component now hits the network on every request (like a normal fetch). To cache, pass { cache: 'force-cache' } or { next: { revalidate: 60 } } explicitly. This bit a lot of people upgrading — assume dynamic now, opt into static.
When: any page that needs data on first paint. This is your replacement for useEffect(() => fetch()). The component is async, runs on the server, and the user gets fully-rendered HTML.
app/dashboard/page.tsx
import { cookies } from 'next/headers' type Project = { id: string; name: string } // Server Component — async, runs on the server, ships no JS. export default async function DashboardPage() { const cookieStore = await cookies() // async in Next 15! const token = cookieStore.get('session')?.value const res = await fetch(`${process.env.API_URL}/projects`, { headers: { Authorization: `Bearer ${token}` }, next: { revalidate: 60 }, // cache 60s — omit for always-fresh }) if (!res.ok) throw new Error('Failed to load projects') const projects: Project[] = await res.json() return ( <ul> {projects.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> ) }
no loading spinner, no useState, no waterfall. The data is already in the HTML, so the page is SEO-friendly and fast. Awaiting the fetch directly in the component is the whole point of the App Router.
in Next 15 cookies(), headers() and params/searchParams are all async — you must await them. And reading cookies() opts the route into dynamic rendering, so revalidate won't make it static; it just caches that one fetch.
When: a create/update/delete form. The modern replacement for "POST to /api/x then update state". The function runs on the server; useActionState gives you pending + error state on the client.
app/projects/actions.ts
'use server' import { z } from 'zod' import { revalidatePath } from 'next/cache' const Schema = z.object({ name: z.string().min(1, 'Name required') }) export type State = { error?: string } export async function createProject(_prev: State, formData: FormData): Promise<State> { const parsed = Schema.safeParse({ name: formData.get('name') }) if (!parsed.success) return { error: parsed.error.issues[0].message } await db.projects.create({ data: parsed.data }) revalidatePath('/projects') // refresh the list (see recipe #10) return {} }
app/projects/new-form.tsx
'use client' import { useActionState } from 'react' import { createProject } from './actions' export function NewForm() { const [state, action, pending] = useActionState(createProject, {}) return ( <form action={action}> <input name="name" /> {state.error && <p role="alert">{state.error}</p>} <button disabled={pending}>{pending ? 'Saving…' : 'Create'}</button> </form> ) }
the form works even before JS loads (progressive enhancement) — like a classic PHP form POST, but you get pending/error state for free once hydrated. No fetch, no JSON, no API route to maintain.
Server Actions are POST-only and the first argument of a useActionState action is the previous state, not the form data — get the order wrong and formData is undefined. Always re-validate inside the action; the client is never to be trusted.
When: you need an actual URL: a webhook receiver, a third-party callback, a public JSON API, or something non-React calls. Server Actions cover forms; Route Handlers cover everything else.
app/api/projects/route.ts
import { NextResponse } from 'next/server' import { z } from 'zod' const Body = z.object({ name: z.string().min(1), color: z.string().optional() }) export async function GET() { const projects = await db.projects.findMany() return NextResponse.json(projects) } export async function POST(req: Request) { const json = await req.json() const parsed = Body.safeParse(json) if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 }) } const created = await db.projects.create({ data: parsed.data }) return NextResponse.json(created, { status: 201 }) }
the file is the route: app/api/projects/route.ts → /api/projects. Export one function per HTTP verb. zod.safeParse + a 422 is your whole validation layer — no middleware framework needed.
don't put page.tsx and route.ts in the same folder — they conflict. And a GET handler is dynamic by default in Next 15; if it reads request or cookies it can't be statically cached, which is usually what you want for an API anyway.
When: something genuinely interactive needs fresh data after load — a live search box, a "load more" button, polling. Drop to the client only here; everything else stays on the server.
app/search/search-box.tsx
'use client' import { useState, useEffect } from 'react' export function SearchBox() { const [q, setQ] = useState('') const [results, setResults] = useState<string[]>([]) useEffect(() => { if (!q) return const ctrl = new AbortController() fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }) .then((r) => r.json()) .then(setResults) .catch(() => {}) return () => ctrl.abort() // cancel stale request }, [q]) return ( <div> <input value={q} onChange={(e) => setQ(e.target.value)} /> <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul> </div> ) }
the AbortController cancels the in-flight request when q changes, so results never arrive out of order. Call your own Route Handler (/api/search) from the browser, never the third-party API directly — that keeps secrets server-side.
a "use client" component cannot be async and cannot read server secrets — anything it touches ships to the browser. If you find yourself fetching on the client just to render once, you wanted a Server Component instead.
When: whole sections require login (/dashboard, /settings). Middleware runs on the edge before the page, so you bounce unauthenticated users before any rendering happens.
middleware.ts (repo root, next to app/)
import { NextResponse, type NextRequest } from 'next/server' export function middleware(req: NextRequest) { const token = req.cookies.get('session')?.value if (!token) { const url = req.nextUrl.clone() url.pathname = '/login' url.searchParams.set('next', req.nextUrl.pathname) // remember target return NextResponse.redirect(url) } return NextResponse.next() } // Only run on protected sections — keeps middleware off public pages. export const config = { matcher: ['/dashboard/:path*', '/settings/:path*'], }
the matcher means this code never even executes for public routes or static assets — cheap and fast. The ?next= param lets you send the user back where they were after login.
middleware runs on the edge runtime: no Node APIs, no database client, no heavy crypto. Only check for presence / verify a JWT signature here — do real authorization in the page or action. Verifying a JWT needs an edge-safe library like jose, not jsonwebtoken.
When: reading the session in a page or action, or setting an httpOnly login cookie after auth. Note: you can only set cookies in a Route Handler or Server Action, never in a plain Server Component render.
app/api/login/route.ts
import { cookies, headers } from 'next/headers' import { NextResponse } from 'next/server' export async function POST(req: Request) { const { token } = await authenticate(await req.json()) const jar = await cookies() jar.set('session', token, { httpOnly: true, // JS can't read it — XSS-safe secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, // 7 days }) return NextResponse.json({ ok: true }) } // Reading elsewhere (a Server Component or action): const ua = (await headers()).get('user-agent') const session = (await cookies()).get('session')?.value
httpOnly means the browser sends the cookie automatically but JavaScript can't read it — the standard defence against token theft via XSS. This is the same idea as a PHP session cookie, just set explicitly.
calling cookies().set() inside a Server Component throws — Next has already started streaming HTML, so there are no headers left to mutate. Move the write into an action or Route Handler. And remember they're all await-ed in Next 15.
When: you call the same backend from multiple places. One wrapper for base URL, auth header, and error handling beats copy-pasting fetch with Bearer everywhere.
lib/api.ts
type Opts = RequestInit & { token?: string } export async function api<T>(path: string, opts: Opts = {}): Promise<T> { const { token, headers, ...rest } = opts const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, { ...rest, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers, }, }) if (!res.ok) { const body = await res.text() throw new Error(`API ${res.status}: ${body}`) } return res.json() as Promise<T> } // Server: const me = await api<User>('/me', { token: serverToken }) // Client: const me = await api<User>('/me') // cookie sent automatically
the generic <T> gives you typed responses at every call site. One place to add a retry, a timeout, or a base-URL change later. This is the everyday wrapper you paste into project after project.
a function shared by server and client can only use NEXT_PUBLIC_ env vars — anything else is undefined in the browser. If a call needs a server secret, pass the token in from the server side (as the comment shows) rather than reading process.env.SECRET inside the shared helper.
When: always, from day one. Stop sprinkling process.env.X! everywhere. Validate once at startup so a missing var fails the build, not a 3am request.
env.ts
import { z } from 'zod' // Server-only secrets — never prefixed, never sent to the browser. const server = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), }) // Browser-exposed — MUST start with NEXT_PUBLIC_. const client = z.object({ NEXT_PUBLIC_API_URL: z.string().url(), }) export const env = { ...server.parse(process.env), // throws on boot if missing ...client.parse(process.env), }
a NEXT_PUBLIC_ var is inlined into the JS bundle at build time and visible to anyone — use it only for non-secrets (public API URLs, analytics keys). Everything else stays server-side. The schema gives you autocomplete and a loud failure instead of silent undefined.
putting a real secret behind NEXT_PUBLIC_ ships it to every browser — a classic leak. And NEXT_PUBLIC_ values are baked in at build time, so changing one in production requires a rebuild, not just a restart.
When: per-page SEO that depends on data — a product name in the <title>, an article image for social sharing. The async sibling of the static metadata export.
app/blog/[slug]/page.tsx
import type { Metadata } from 'next' type Props = { params: Promise<{ slug: string }> } export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params // params is async in Next 15 const post = await getPost(slug) return { title: post.title, description: post.excerpt, openGraph: { title: post.title, images: [{ url: post.coverImage }], }, } } export default async function Page({ params }: Props) { const { slug } = await params const post = await getPost(slug) return <article>{post.body}</article> }
Next renders these tags into the server HTML, so crawlers and link-preview bots see real titles and images — something a client-side SPA can't reliably do. Define metadata (static object) for fixed pages, generateMetadata (async fn) when it depends on data.
if both the page and generateMetadata call getPost(slug), that's fine — Next 15 dedupes identical fetches within a request automatically (request memoization). But a non-fetch data source won't dedupe; wrap it in React's cache() to avoid the double query.
When: a Server Action changed data and a cached page or list now shows stale content. revalidatePath for a route, revalidateTag for everything sharing a tag.
app/projects/actions.ts
'use server' import { revalidatePath, revalidateTag } from 'next/cache' export async function deleteProject(id: string) { await db.projects.delete({ where: { id } }) revalidatePath('/projects') // re-render this route's data revalidateTag('projects') // + any fetch tagged 'projects', anywhere }
app/projects/page.tsx — tag the fetch you want to invalidate
const res = await fetch(`${env.NEXT_PUBLIC_API_URL}/projects`, { next: { tags: ['projects'], revalidate: 3600 }, })
tagging decouples the write from the read: the action doesn't need to know which pages display projects — it just invalidates the 'projects' tag and every cached fetch with that tag refreshes. Cleaner than chasing down each path.
revalidatePath/revalidateTag only work inside a Server Action or Route Handler — not during a normal render. And they invalidate the cache; the data refetches on the next visit, so they're not an instant push to currently-open tabs.
When: one slow query shouldn't block the whole page. Wrap the slow part in <Suspense> and Next streams the shell immediately, swapping in the content when it's ready. loading.tsx does this for a whole route.
app/dashboard/loading.tsx
// Auto-shown while page.tsx awaits — wraps the route in Suspense for you. export default function Loading() { return <div className="skeleton">Loading dashboard…</div> }
app/dashboard/page.tsx — stream just the slow widget
import { Suspense } from 'react' async function SlowStats() { const stats = await getExpensiveStats() // 2s query return <div>{stats.total}</div> } export default function Page() { return ( <> <h1>Dashboard</h1> // shows instantly <Suspense fallback={<p>Crunching numbers…</p>}> <SlowStats /> // streams in when ready </Suspense> </> ) }
the user sees the heading and layout immediately while the slow widget streams in — far better perceived performance than one blank page until everything's done. loading.tsx is the zero-config version for an entire route.
the fallback only shows for components that actually suspend (i.e. await something). Awaiting in the page body outside any Suspense boundary blocks the whole route again — push the slow await down into a child component so only that piece waits.
When: a record doesn't exist (show a 404) or a render throws (show a recoverable error UI). not-found.tsx + notFound() for the former, error.tsx for the latter.
app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation' export default async function Page({ params }: Props) { const { slug } = await params const post = await getPost(slug) if (!post) notFound() // renders nearest not-found.tsx, sets 404 return <article>{post.body}</article> }
app/blog/[slug]/not-found.tsx
export default function NotFound() { return <p>That post doesn't exist.</p> }
app/blog/error.tsx — MUST be a Client Component
'use client' export default function Error({ error, reset }: { error: Error; reset: () => void }) { return ( <div> <p>Something went wrong.</p> <button onClick={reset}>Try again</button> // re-renders the segment </div> ) }
notFound() throws a special control-flow signal Next catches — clean for "no row found", and it sets a real 404 status for SEO. error.tsx wraps the segment in an error boundary with a reset() to retry without a full reload.
error.tsx must be "use client" (error boundaries are a client feature) and it does not catch errors in the root layout — use global-error.tsx for those. Also, error.tsx won't catch notFound(); that's routed to not-found.tsx on purpose.
When: any image or web font in production. next/image gives you lazy loading, modern formats, and reserved space (no CLS). next/font self-hosts Google fonts at build time — no extra network request, no FOUT.
app/layout.tsx
import { Inter } from 'next/font/google' const inter = Inter({ subsets: ['latin'], display: 'swap' }) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={inter.className}> <body>{children}</body> </html> ) }
app/products/[id]/page.tsx
import Image from 'next/image' <Image src={product.photo} alt={product.name} width={600} height={400} // reserves space → zero layout shift priority // preload above-the-fold hero images only />
width/height let the browser reserve the box before the image loads, killing the layout jump that hurts your CLS score. next/font downloads the font at build time and serves it from your own domain — faster and privacy-friendly.
remote image hosts must be allow-listed in next.config.ts under images.remotePatterns, or <Image> throws. Use priority only on the one hero image — slapping it on everything defeats lazy loading and tanks performance.
cookies()/params (Functions reference), Server Actions, Route Handlers, and generateMetadata.