Next.js 15 Cheatsheet — App Router, Server Components, Server Actions, Real Examples and the Pitfalls Behind Them
Next.js 15 cheat sheet — App Router, Server Components, Server Actions, dynamic routes, with examples and pitfalls.
Runs locally
CategoryDeveloper & DevOps
Best forFormatting, validating, shrinking, or inspecting code-adjacent text.
143 snippets
Routing (23)
app/layout.tsx — root layout (required)
Every app/ project needs exactly one root layout.tsx that renders <html> and <body>. It wraps every page and persists across navigations. Children prop is the active page or nested layout.
page.tsx makes a route segment publicly accessible. app/page.tsx is "/", app/about/page.tsx is "/about". Without a page.tsx, the segment is not routable even if a layout exists.
A loading.tsx file automatically wraps the segment in <Suspense>. Shown instantly during navigation while the page streams in. Co-locate per segment for granular skeletons.
error.tsx is a Client Component error boundary scoped to its route segment. Receives error + reset props. Does NOT catch errors thrown in the root layout — use global-error.tsx for that.
Rendered whenever notFound() is called in a Server Component (or no segment matches). Place at app/not-found.tsx for the global 404, or per-segment for scoped ones.
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
const product = await db.product.find(id);
if (!product) notFound();
// app/products/[id]/not-found.tsx
export default function NotFound() {
return <p>Product not found.</p>;
}
template.tsx — re-mounted layout
Same shape as layout.tsx, but creates a NEW instance on every navigation. State and effects re-fire. Use when you actually want fresh state on each route — analytics page views, exit-animation triggers.
// app/(marketing)/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
return <div className="fade-in">{children}</div>;
}
Dynamic segment [slug]
Brackets in a folder name make that segment dynamic. The value lands in the page's params prop. In Next 15 params is a Promise — you must await it.
Double brackets make the catch-all optional — the parent path matches too. /docs and /docs/a/b both hit the same page. params.slug is undefined for the bare /docs.
Wrap a folder name in parentheses to group routes WITHOUT adding to the URL. Useful for sharing a layout across unrelated routes — e.g. (marketing)/about and (marketing)/pricing share one layout, paths stay /about /pricing.
Folders prefixed with @ create named slots that render in parallel inside the same layout. The layout receives each slot as a prop. Great for split views, modals, and dashboards.
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<>
{children}
{analytics}
{team}
</>
);
}
// app/@analytics/page.tsx and app/@team/page.tsx fill the slots.
Intercepting routes (.)folder
Dot-prefixed segments intercept a sibling/parent route when navigated via <Link>. The classic use case is opening a photo as a modal over the feed without losing the underlying URL on direct visit.
app/
feed/page.tsx
photo/[id]/page.tsx
feed/
(..)photo/[id]/page.tsx // intercept /photo/[id] from /feed
searchParams in pages
Pages receive a searchParams prop containing the query string. In Next 15 this is also a Promise — await it. Reading searchParams opts the page out of static rendering.
next/link does soft navigation, prefetches by default on viewport, preserves layout state. Use href, not onClick + router.push, for normal anchor links.
import Link from 'next/link';
<Link href="/about" prefetch>About</Link>
<Link href="/dashboard" replace>Replace history</Link>
useRouter — programmatic navigation (client)
Import from "next/navigation" (NOT "next/router" — that's the pages-router one). Use in Client Components for push, replace, refresh, back, forward.
'use client';
import { useRouter } from 'next/navigation';
export function LogoutButton() {
const router = useRouter();
return (
<button onClick={() => router.replace('/login')}>
Log out
</button>
);
}
redirect() in Server Components
redirect() throws an internal error that the framework converts into a 307. Call it in Server Components or Server Actions; never wrap it in try/catch — you would swallow the redirect.
import { redirect } from 'next/navigation';
export default async function Page() {
const user = await getUser();
if (!user) redirect('/login');
return <Dashboard user={user} />;
}
usePathname — read the current path (client)
Import from "next/navigation". Returns the current pathname as a string. The classic use is highlighting the active nav link. It updates on every client-side navigation, so the component re-renders.
Returns a read-only URLSearchParams. Because it makes the component depend on the URL, Next requires the nearest parent to be wrapped in <Suspense> during static rendering, or the whole route is forced dynamic.
'use client';
import { useSearchParams } from 'next/navigation';
export function Filter() {
const params = useSearchParams();
const sort = params.get('sort') ?? 'new';
return <span>Sorting by {sort}</span>;
}
useParams — read dynamic params (client)
A Client Component hook that returns the current route's dynamic params as a plain object. Unlike the page params prop, this one is NOT a Promise. Handy when a deep client component needs the [id] without prop drilling.
router.refresh — re-fetch server data without losing state
Calls the server for fresh data for the current route and merges it in without a full reload. Client component state (inputs, scroll) is preserved. Pair it with a Server Action mutation when you want the list to update in place.
redirect() emits a temporary 307; permanentRedirect() emits a permanent 308 that browsers and crawlers cache. Use it for moved-forever URLs (renamed slugs, merged accounts) so search engines transfer ranking.
import { permanentRedirect } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ old: string }> }) {
const { old } = await params;
const fresh = await resolveNewSlug(old);
if (fresh) permanentRedirect('/posts/' + fresh);
}
default.tsx — fallback for unmatched parallel slots
When a parallel @slot has no segment matching the current URL on a hard navigation, Next renders default.tsx for that slot. Without it, the slot 404s on direct visits. Return null to render nothing.
// app/@modal/default.tsx
export default function Default() {
return null; // nothing in the modal slot on direct visits
}
Linking with scroll={false}
By default <Link> and router.push scroll to the top of the page after navigation. Pass scroll={false} to keep the current scroll position — useful for tab switches or filter changes that should feel in-place.
In app/, every file is a Server Component unless marked otherwise. Server Components run only on the server, can be async, read secrets, hit the DB directly, ship zero JS for themselves.
// app/posts/page.tsx — Server Component (no "use client")
import { db } from '@/lib/db';
export default async function Posts() {
const posts = await db.post.findMany(); // direct DB access
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
"use client" — opt into a Client Component
Add "use client" at the very top of the file (before any import). Required for hooks, browser APIs, event handlers, and stateful interactivity. Everything imported into this file becomes part of the client bundle.
A Server Component can render a Client Component and pass serializable props. The reverse needs the children pattern — pass server-rendered children into a client wrapper as a prop.
// server component
import { ClientShell } from './ClientShell';
import { ServerHeavy } from './ServerHeavy';
export default function Page() {
return (
<ClientShell>
<ServerHeavy /> {/* still a Server Component */}
</ClientShell>
);
}
Server Component CAN be async
Server Components support top-level await — that is the entire point. Fetch directly in the component body, no useEffect, no SWR, no loaders.
A Client Component that is async will render as a Promise and React will warn. Use useEffect + setState, useSWR, useQuery, or the new use() hook to consume a promise instead.
// WRONG
'use client';
export default async function Bad() { /* React will warn */ }
// RIGHT — use the use() primitive
'use client';
import { use } from 'react';
export default function Good({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise);
return <pre>{JSON.stringify(data)}</pre>;
}
When to choose Server vs Client
Default to Server. Reach for Client only when you need: hooks, event listeners, browser-only APIs (window, localStorage), or third-party libs that touch the DOM. Push Client Components as far down the tree as possible to shrink the JS payload.
// Page (Server) — fetches data
// └── Filters (Client) — handles input state
// └── List (Server) — renders items
// └── ItemActions (Client) — like / bookmark buttons
server-only — guarantee a module never reaches the client
Import "server-only" at the top of a module that touches secrets. If a Client Component ever imports it transitively, the build fails with a clear error. Cheap insurance for API keys, DB credentials, internal SDKs.
// lib/db.ts
import 'server-only';
import { Pool } from 'pg';
export const pool = new Pool({ connectionString: process.env.DB_URL });
Passing a Server Action as a prop to a Client Component
Server Actions are the one kind of function you CAN pass across the server/client boundary. The framework replaces it with a reference the client invokes over the network. This keeps the action definition on the server while the button lives in the client.
// server component
import { deletePost } from './actions';
import { DeleteButton } from './DeleteButton';
export default function Row({ id }: { id: string }) {
return <DeleteButton onDelete={deletePost.bind(null, id)} />;
}
// DeleteButton.tsx
'use client';
export function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
return <button onClick={() => onDelete()}>Delete</button>;
}
use() to unwrap a promise in a Client Component
A Server Component can start a fetch and pass the un-awaited promise down to a Client Component, which unwraps it with React 19's use(). The Client Component suspends until it resolves, so wrap it in <Suspense>.
// server
<Suspense fallback={<Skeleton />}>
<Comments commentsPromise={getComments(postId)} />
</Suspense>
// client
'use client';
import { use } from 'react';
export function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>;
}
client-only — guard a browser-only module
The mirror of server-only. Import "client-only" at the top of a module that touches window, document, or a browser-only SDK. If a Server Component ever imports it, the build fails instead of crashing at runtime.
React Context relies on hooks, so any provider component must be a Client Component. Wrap it once near the root, then Server Components rendered as children still work — children are passed through, not re-rendered on the client.
'use client';
import { createContext } from 'react';
export const ThemeContext = createContext('light');
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
// app/layout.tsx: <ThemeProvider>{children}</ThemeProvider>
connection() — opt out of static rendering explicitly
Await connection() (from next/server) to tell Next this render must wait for an incoming request, forcing dynamic rendering even without reading cookies or headers. Cleaner than the old unstable_noStore for "this must run per-request".
import { connection } from 'next/server';
export default async function Page() {
await connection();
const rnd = Math.random(); // now safe: runs per request, not at build
return <p>{rnd}</p>;
}
Data fetching (16)
fetch() with default caching
Next 15 changed the default: fetch() is no longer cached by default. To cache, opt in with { cache: "force-cache" }. The framework dedupes identical fetches within a single render.
// Cache forever (until next deploy)
const data = await fetch(url, { cache: 'force-cache' }).then((r) => r.json());
revalidate — time-based ISR
Pass next: { revalidate: seconds } to refresh the cached response in the background after N seconds. The first request after expiry returns stale + triggers a fresh fetch for the next one.
// re-fetch at most every 60 seconds
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
}).then((r) => r.json());
cache: "no-store" — always fresh
For data that must be live every request — dashboards, account balances, anything personalized. Marks the route as dynamic.
Tag a fetch and you can purge it on demand from a Server Action or webhook. Multiple tags, multiple fetches — one revalidateTag() call invalidates them all.
const posts = await fetch('/api/posts', {
next: { tags: ['posts'] },
}).then((r) => r.json());
// elsewhere, after creating a post:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
export const dynamic = "force-static"
Force the route to be statically rendered even if it uses dynamic APIs (cookies, headers, searchParams). Calls to those APIs return empty values.
Opposite of force-static: opt the route into dynamic rendering on every request, even if the code looks fully static. Useful when downstream behavior depends on a header you have not yet read.
Return an array of params from generateStaticParams in a dynamic segment to pre-render those pages at build time. Combine with dynamicParams = false to 404 anything not listed.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } });
return posts.map((p) => ({ slug: p.slug }));
}
export const dynamicParams = false; // 404 anything not in the list
unstable_cache — wrap arbitrary functions
Cache the result of an arbitrary async function (DB calls, third-party SDKs that do not use fetch). Same keying + revalidate + tags story as fetch.
next/headers exposes the incoming request's cookies and headers in Server Components and Server Actions. In Next 15 both are async (return Promises) — await them.
import { cookies, headers } from 'next/headers';
export default async function Page() {
const c = await cookies();
const session = c.get('session')?.value;
const h = await headers();
const country = h.get('x-vercel-ip-country');
return <p>{session} · {country}</p>;
}
Parallel data fetching with Promise.all
Sequential awaits in a Server Component waterfall. Use Promise.all to fan out independent requests in parallel and cut TTFB.
Set revalidate at the route segment level to give every cached fetch and the page itself a default revalidation window. A per-fetch next.revalidate still overrides it. Set it to 0 to make the segment fully dynamic.
// app/blog/page.tsx
export const revalidate = 3600; // regenerate at most once an hour
export default async function Blog() {
const posts = await fetch('https://api.acme.com/posts').then((r) => r.json());
return <PostList posts={posts} />;
}
export const fetchCache — pin caching behavior
A segment-level escape hatch that overrides the default caching of every fetch in the segment. "force-cache" caches even fetches that opted out; "default-no-store" makes them all dynamic. Reach for it only when individual fetch options are not enough.
// app/dashboard/page.tsx
export const fetchCache = 'default-no-store'; // everything live by default
dynamicParams — 404 vs render-on-demand
In a dynamic segment with generateStaticParams, dynamicParams = true (the default) renders any unlisted path on demand and caches it. Set it to false to return a 404 for anything not pre-listed at build time.
next/headers draftMode() reads whether the current request is in draft mode (a signed cookie set via enable()). Use it to fetch unpublished CMS content for editors while everyone else sees the cached published version. It is async in Next 15.
Wrap a data-loading function in React's cache() so multiple Server Components in the same request share one result instead of hitting the DB N times. Scoped to a single request — no cross-request caching, unlike unstable_cache.
import { cache } from 'react';
import { db } from '@/lib/db';
export const getUser = cache((id: string) =>
db.user.findUnique({ where: { id } }),
);
// layout and page both call getUser(id) → one query
after() — run work after the response streams
after() (from next/server) schedules a callback to run once the response has finished streaming to the user. Perfect for logging, analytics, or cache warming that must not block time-to-first-byte.
import { after } from 'next/server';
export default async function Page() {
after(async () => {
await logPageView(); // runs after the user already has the HTML
});
return <Content />;
}
Server Actions (12)
"use server" — declare a Server Action
Put "use server" at the top of a file (or inside an async function) to expose its async functions as RPC endpoints callable from the client. Top-of-file form is required for a file-level module; the inline form must be the first statement of the function body.
// app/actions.ts — file-level
'use server';
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string;
await db.todo.create({ data: { title } });
}
<form action={serverAction}>
A Server Action wired to a form's action prop works without JS — the form posts to the server, the action runs, and the page revalidates. No fetch in the client, no API route to maintain.
React 19 hook. Wraps a Server Action so you get [state, formAction, isPending]. Perfect for inline validation errors that should survive without JS as well.
React 19 hook that reads pending / data / method / action of the parent <form>. Must be called from a component RENDERED INSIDE the form — not the form's own component, or it will always return pending=false.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Submitting...' : 'Submit'}</button>;
}
// In the parent form:
// <form action={...}><SubmitButton /></form>
revalidatePath / revalidateTag in actions
After a mutation, call revalidatePath("/todos") or revalidateTag("todos") inside the action so the next render serves fresh data. Without it, the UI stays stale until the next full navigation.
'use server';
import { revalidatePath } from 'next/cache';
export async function deleteTodo(id: string) {
await db.todo.delete({ where: { id } });
revalidatePath('/todos');
}
Server Actions from Client Components
Import the action and call it like a normal async function (button onClick, custom hook, whatever). The framework handles the network call, serialization, and error reporting.
'use client';
import { deleteTodo } from '@/app/actions';
export function DeleteButton({ id }: { id: string }) {
return <button onClick={() => deleteTodo(id)}>Delete</button>;
}
Action input validation with Zod
Anything coming from the browser is untrusted. Validate FormData with zod (or whatever) and return early on invalid input — your DB call must NEVER see raw user data.
A form action only receives FormData. To also pass an id or context, bind it: action.bind(null, id). The bound args arrive before the FormData arg, and they travel safely server-side, never exposed in the DOM.
'use server';
export async function updateTodo(id: string, formData: FormData) {
await db.todo.update({ where: { id }, data: { title: formData.get('title') as string } });
}
// in the form
<form action={updateTodo.bind(null, todo.id)}>
<input name="title" defaultValue={todo.title} />
</form>
Hidden inputs to pass data into an action
An alternative to bind: drop a hidden input inside the form and read it from FormData. Simple, works without JS. But anything in a hidden field is editable by the client, so still validate it server-side.
'use server';
export async function vote(formData: FormData) {
const id = formData.get('postId') as string;
await db.post.update({ where: { id }, data: { votes: { increment: 1 } } });
}
// form
<form action={vote}>
<input type="hidden" name="postId" value={post.id} />
<button>Upvote</button>
</form>
Optimistic UI with useOptimistic
React 19's useOptimistic shows the expected result immediately while the Server Action runs, then reconciles when the real data lands (or rolls back on error). Great for likes, todos, and chat sends.
Give your action a typed state shape so the form can render field-level errors that survive even without JS. The action returns the new state; useActionState threads it back into the form on the next render.
Every Server Action is a POST request. Next checks the Origin against the Host header to block cross-site invocation, so basic CSRF is handled. You still must do your own authn/authz inside the action — the framework does not know who is allowed.
'use server';
import { auth } from '@/lib/auth';
export async function deleteAccount(id: string) {
const session = await auth();
if (session?.user.id !== id) throw new Error('Forbidden');
await db.user.delete({ where: { id } });
}
Streaming (5)
Suspense boundaries for streaming
Wrap a slow Server Component in <Suspense> to stream its shell first and let the slow part swap in as it resolves. The user sees usable UI immediately instead of staring at a blank page.
Call a void preload(id) at the top of a layout or page to kick off a cached fetch before the component that needs it renders. Combined with React cache(), the later await is instant because the request already started.
import { cache } from 'react';
const getItem = cache((id: string) => fetch('/api/item/' + id).then((r) => r.json()));
export const preload = (id: string) => { void getItem(id); };
export default function Layout({ children }: { children: React.ReactNode }) {
preload('42'); // warm the cache while children render
return <>{children}</>;
}
Suspense around useSearchParams
A Client Component that calls useSearchParams must sit under a <Suspense> boundary, or static prerendering of the whole page fails the build (the "missing suspense boundary" error). Wrap just the part that reads the query.
import { Suspense } from 'react';
import { SearchBox } from './SearchBox'; // uses useSearchParams
export default function Page() {
return (
<Suspense fallback={null}>
<SearchBox />
</Suspense>
);
}
Metadata (9)
Static metadata export
Export a constant called metadata from any layout.tsx or page.tsx and Next renders the <head> tags for you — title, description, OpenGraph, robots, viewport.
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About — Acme',
description: 'Acme makes calm tooling.',
openGraph: { images: ['/og/about.png'] },
};
generateMetadata — dynamic
When the metadata depends on params or fetched data, export an async generateMetadata function. Fetches inside it are deduped with fetches in the page itself.
Set metadata.title to { template: "%s · Acme", default: "Acme" } in the root layout. Child pages that set a string title get "Whatever · Acme" automatically.
Drop app/sitemap.ts (or sitemap.xml/route.ts) exporting a function that returns the sitemap entries. Same idea for app/robots.ts. Both are auto-cached + auto-revalidated.
Set metadataBase in the root layout so relative image paths in openGraph/twitter become absolute URLs. Without it Next warns and social scrapers may fail to load your card image because they need an absolute href.
Declare metadata.alternates.canonical to emit <link rel="canonical">. Essential when the same content is reachable from multiple URLs (with/without query params), so search engines consolidate ranking signals onto one.
In current Next, viewport and themeColor moved out of metadata into their own viewport export (a Viewport object or generateViewport). Keeping them in metadata still works but logs a deprecation warning.
import type { Viewport } from 'next';
export const viewport: Viewport = {
themeColor: '#0b0b0f',
width: 'device-width',
initialScale: 1,
};
icons & manifest in metadata
You can declare favicons, apple-touch-icons, and the web app manifest through metadata.icons / metadata.manifest. Or skip config entirely and drop icon.png / apple-icon.png / manifest.ts into app/ — Next wires the tags for you.
next/image automatically serves modern formats (AVIF / WebP), generates responsive srcsets, and lazy-loads off-viewport. width + height are required for non-fill images so the browser reserves space (no CLS).
When you do not know the image dimensions, use fill. The parent MUST have position: relative (or absolute/fixed) and a defined size, or the image collapses to zero.
next/image refuses to optimize remote hosts you have not allowlisted. Add them under images.remotePatterns in next.config — keeps you from accidentally proxying the whole internet.
sizes — tell the browser how big the image renders
For responsive (fill or width:100%) images, set sizes so the browser picks the right srcset candidate instead of downloading the largest. A wrong or missing sizes is the most common cause of oversized image downloads.
next/image defaults to quality 75. Lower it for thumbnails to shrink bytes; raise it for hero shots where compression artifacts show. In current Next you must allowlist non-default values via images.qualities in next.config.
Set unoptimized on an image (or globally in next.config) to serve the original file untouched. Needed for static exports without an image server, animated GIFs you do not want re-encoded, or SVGs.
Point next/image at Cloudinary, imgix, or your own CDN with a loader function that builds the transformation URL. Returning width/quality params lets the CDN do the resizing instead of the Next optimizer.
Imports the font at build time and self-hosts the .woff2 — no runtime request to fonts.googleapis.com, no extra DNS lookup, no FOUT. Subsets only the glyphs you ask for.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
next/font/local — self-hosted custom font
Same loading optimizations as next/font/google, but for fonts you own. Point at the .woff2 file relative to the importing file.
Pass variable to a font loader to expose it as a CSS custom property instead of a className. Attach the variable class to <html>, then reference var(--font-sans) in Tailwind's fontFamily config.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
export default function Layout({ children }: { children: React.ReactNode }) {
return <html className={inter.variable}><body>{children}</body></html>;
}
// tailwind.config: fontFamily: { sans: ['var(--font-sans)'] }
Multiple weights and the adjustFontFallback default
Variable Google fonts need no weight list; for static fonts pass the weights you actually use to keep bundles small. next/font also auto-computes a size-adjusted system fallback to cut layout shift while the web font loads.
import { Roboto } from 'next/font/google';
const roboto = Roboto({
subsets: ['latin'],
weight: ['400', '700'], // only what you use
display: 'swap',
});
Middleware (6)
middleware.ts — request middleware
Place at the project root (next to app/, not inside). Runs on the Edge before the page renders. Rewrite, redirect, set headers, gate routes. NO Node APIs, NO long work — keep it microseconds.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const session = req.cookies.get('session');
if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
middleware matcher — scope the middleware
Export a config.matcher to limit which paths the middleware runs on. Exclude static assets explicitly — running middleware on every PNG is a waste.
export const config = {
matcher: [
/*
* Match all paths except:
* - _next/static / _next/image
* - favicon.ico
* - any file with an extension (image, font, etc.)
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
],
};
Rewriting + setting request headers
NextResponse.rewrite swaps the matched path for another internal one — the URL bar does NOT change. You can also clone the headers and set extras the downstream Server Component can read via headers().
Read incoming cookies from req.cookies and write outgoing ones on the response with res.cookies.set. Common for rotating a session token or stamping a first-visit A/B bucket that downstream pages can read.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const res = NextResponse.next();
if (!req.cookies.get('bucket')) {
res.cookies.set('bucket', Math.random() < 0.5 ? 'A' : 'B', { path: '/' });
}
return res;
}
Geolocation and IP in middleware
On platforms like Vercel, geo data arrives as request headers (x-vercel-ip-country, x-vercel-ip-city). Read them in middleware to redirect to a locale or gate region-restricted content before the page renders.
export function middleware(req: NextRequest) {
const country = req.headers.get('x-vercel-ip-country');
if (country === 'DE' && req.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/de', req.url));
}
return NextResponse.next();
}
middleware runtime: nodejs (experimental)
Middleware runs on the Edge runtime by default. Recent Next versions let you opt a middleware into the Node.js runtime via the experimental nodeMiddleware flag, unlocking Node APIs at the cost of slower cold starts.
Drop a route.ts in any segment that is NOT already serving a page.tsx. Export named HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). Each receives a Request and returns a Response.
// app/api/ping/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ ok: true, ts: Date.now() });
}
export async function POST(req: Request) {
const body = await req.json();
return NextResponse.json({ echo: body });
}
Dynamic route params in route.ts
route.ts in a dynamic segment receives params as the second arg, same Promise shape as pages. await it before using.
// app/api/users/[id]/route.ts
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
return Response.json(user);
}
Caching route handlers
GET handlers are static by default ONLY when they do not use dynamic APIs. To force caching behavior use the route segment config: export const dynamic = "force-static" or revalidate = N.
Return a Response wrapping a ReadableStream to stream tokens / SSE / chunked output. Critical for LLM responses where you do not want the user staring at a spinner for 15 seconds.
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ['hello', ' ', 'world']) {
controller.enqueue(new TextEncoder().encode(chunk));
await new Promise((r) => setTimeout(r, 200));
}
controller.close();
},
});
return new Response(stream, { headers: { 'content-type': 'text/plain' } });
}
Reading query params in a route handler
A route handler gets a standard Request. Build a URL from request.url to read the query via searchParams. Reading searchParams marks the handler dynamic, so it runs per request rather than being cached.
There is no automatic CORS in App Router. Set the headers yourself and handle the OPTIONS preflight explicitly when a browser on another origin must call your endpoint.
const cors = {
'Access-Control-Allow-Origin': 'https://app.acme.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: cors });
}
export async function GET() {
return Response.json({ ok: true }, { headers: cors });
}
Setting cookies from a route handler
Use the cookies() helper from next/headers (async in Next 15) inside a route handler to set a cookie on the response — handy for login endpoints that issue a session. Setting cookies opts the handler out of static caching.
Return new Response with an explicit status for errors, or use NextResponse.redirect for a 3xx. For validation failures, 422 with a JSON body of field errors is friendlier to clients than a bare 400.
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.json();
if (!body.email) {
return NextResponse.json({ errors: { email: 'required' } }, { status: 422 });
}
return NextResponse.redirect(new URL('/welcome', req.url), 303);
}
Env vars (4)
NEXT_PUBLIC_ vs server-only env
Only variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle. Everything else is server-only. NEVER prefix secrets — once published, they are public forever.
# .env.local
DATABASE_URL=postgres://... # server-only
NEXT_PUBLIC_GA_ID=G-XXXXXXX # client-visible
// usage
const db = process.env.DATABASE_URL; // OK on server only
const ga = process.env.NEXT_PUBLIC_GA_ID; // OK anywhere
.env load order
.env.local always wins over .env.development.local > .env.development > .env. .env.local is gitignored by default — put real secrets only there.
# load order (highest priority first)
.env.local
.env.[mode].local # mode = development | production | test
.env.[mode]
.env
Validate env at startup with a schema
A missing env var should fail loud at boot, not silently as undefined deep in a request. Parse process.env with zod in a single env.ts module and import that everywhere instead of touching process.env directly.
// env.ts
import { z } from 'zod';
export const env = z
.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().min(1),
})
.parse(process.env);
env in next.config — inline non-prefixed vars
The env key in next.config inlines selected server vars into the client bundle at build time, even without the NEXT_PUBLIC_ prefix. Use it sparingly and never for secrets — the value still ends up public.
// next.config.ts
export default {
env: { BUILD_ID: process.env.GIT_SHA ?? 'dev' },
};
// client code: process.env.BUILD_ID is replaced at build time
Deploy (9)
Vercel — zero-config deploy
Push to a GitHub repo, import on Vercel, done. Vercel detects Next, builds with its own optimized pipeline, gives you preview URLs per PR, automatic Edge for middleware, and image CDN.
# zero config needed
vercel # one-off deploy from CLI
vercel --prod # production deploy
# or just connect the GitHub repo in dashboard
output: "standalone" — self-host friendly build
next.config output: "standalone" produces a minimal .next/standalone/ folder with the server, its deps, and a node server.js. Drop into a Docker image about 10x smaller than copying node_modules.
export const runtime = "edge" makes a page or route handler run on the V8 isolate-based Edge runtime instead of Node. Fast cold-start, but NO Node APIs (no fs, no Buffer in some hosts).
Runs the TypeScript checker (unless ignoreBuildErrors), ESLint (unless ignoreDuringBuilds), generates the route manifest, statically renders SSG routes, builds the client bundle with Turbopack/webpack, and prints the route table.
After next build, next start boots the optimized Node server that serves the prebuilt output. It will not pick up source changes — that is what next dev is for. Self-hosters run build then start; PM2 or a container keeps it alive.
pnpm next build
pnpm next start -p 3000
# behind a process manager
pm2 start "pnpm next start" --name web
basePath — serve the app under a subpath
Set basePath when the app lives at /app instead of the domain root. Next prefixes routes, <Link>, and assets automatically. You still write hrefs without the prefix; only hardcoded string URLs need manual care.
// next.config.ts
export default { basePath: '/app' };
// /about now serves at /app/about; <Link href="/about" /> still works
Caching with Cache-Control on static assets
Files under _next/static are content-hashed and safe to cache immutably for a year. For your own public/ files or route handlers, set Cache-Control via the headers() config or a Response header to control CDN behavior.
For static, config-time routing rules, use the redirects() and rewrites() async functions instead of middleware. redirects change the URL bar; rewrites proxy internally while keeping the URL. They run before middleware.
Export a register() function from instrumentation.ts at the project root to run code once when the server process starts — wiring up OpenTelemetry, Sentry, or a metrics exporter. It runs in both Node and Edge runtimes.
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./otel'); // start tracing in the Node runtime only
}
}
Pitfalls (26)
Pitfall — Client Component cannot be async
Marking a "use client" component as async renders a Promise. React 19 warns at runtime. Either keep it sync and lift the fetch to a Server Component, or consume a promise with use() / useSWR / useQuery.
// WRONG
'use client';
export default async function Bad() { /* renders a Promise */ }
// RIGHT — pass a promise prop and consume it with use()
'use client';
import { use } from 'react';
export default function Good({ p }: { p: Promise<X> }) {
const x = use(p);
return <pre>{JSON.stringify(x)}</pre>;
}
Pitfall — Server Action must be called from a form (or imported)
You cannot fetch("/...some-server-action") directly. Either pass the action to a form action prop, or import the function and call it from a Client Component. The framework wires the RPC channel.
// from a Client Component
'use client';
import { createTodo } from '@/app/actions';
<button onClick={() => createTodo(formData)}>Add</button>
// or from a form
<form action={createTodo}><input name="title" /></form>
Pitfall — hooks fail in Server Components
useState, useEffect, useRef etc. throw if the file is not "use client". Either add the directive or split the interactive bit into its own client child.
// WRONG — server component cannot use hooks
export default function Bad() {
const [n, setN] = useState(0); // throws
}
// RIGHT
'use client';
import { useState } from 'react';
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Pitfall — hydration mismatch
Server and client must render the same initial HTML. Date.now(), Math.random(), reading window or localStorage during render — all cause mismatches. Move them into useEffect, or gate with a mounted flag, or use suppressHydrationWarning sparingly.
'use client';
import { useEffect, useState } from 'react';
export function Clock() {
const [now, setNow] = useState<string>(''); // empty on first render
useEffect(() => {
setNow(new Date().toLocaleTimeString());
}, []);
return <time>{now}</time>;
}
Pitfall — "use server" must be top-of-file
The file-level directive MUST be the very first non-comment, non-whitespace line. If it sits below an import, it silently does nothing — your action becomes a regular server function and the client cannot call it.
// WRONG — directive below import is ignored
import { z } from 'zod';
'use server'; // silently does nothing
// RIGHT
'use server';
import { z } from 'zod';
Pitfall — "use client" cuts everything below it
Once you mark a file "use client", every module it imports (transitively) becomes part of the client bundle. Push the directive down to the smallest possible leaf — usually a single Button or Toggle.
// app/page.tsx (Server) — keep this server
import { Counter } from './Counter'; // Client leaf
export default function Page() {
return <><HeavyServerStuff /><Counter /></>;
}
// app/Counter.tsx — minimal client surface
'use client';
import { useState } from 'react';
export function Counter() { /* ... */ }
Pitfall — params and searchParams are Promises in Next 15
In Next 15 every page receives params and searchParams as Promises and must await them. Sync access works in dev with a warning, breaks in production builds. Same for cookies() and headers().
Pitfall — fetch is NOT cached by default in Next 15
Pre-15, fetch defaulted to force-cache. From 15, it defaults to no-store. If your app suddenly hits your API on every request after upgrading, opt back in with { cache: "force-cache" } or { next: { revalidate: N } }.
// Next 14: cached by default
await fetch(url);
// Next 15: NOT cached by default. To cache:
await fetch(url, { cache: 'force-cache' });
await fetch(url, { next: { revalidate: 60 } });
Pitfall — useFormStatus reads the parent form
useFormStatus only sees a form when called from a component RENDERED INSIDE that form. Put it in the same component that owns the <form> and it always reports pending=false. Always extract a <SubmitButton /> child.
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus(); // sees the parent <form>
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
// parent:
// <form action={action}><SubmitButton /></form>
Pitfall — middleware cannot use Node APIs
middleware runs in the Edge runtime. No fs, no path, no crypto.createHash, no anything Node-specific. Use Web Crypto, fetch, URL, Request, Response. If you need Node, do the work in a route handler instead.
// WRONG
import fs from 'node:fs'; // build fails
// RIGHT — Web Crypto in middleware
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode('hello'),
);
Pitfall — NEXT_PUBLIC_ vars are baked in at build time
Setting NEXT_PUBLIC_FOO at runtime on the server does nothing for the client bundle — the value is replaced literally at build time. To change it, rebuild. If you need runtime config, expose it through a route handler.
// next build runs with NEXT_PUBLIC_API=https://prod.acme.com
// process.env.NEXT_PUBLIC_API is REPLACED at build:
console.log(process.env.NEXT_PUBLIC_API); // "https://prod.acme.com"
// changing env at runtime does NOT change the client bundle.
Pitfall — passing functions across the server/client boundary
A Server Component can pass plain serializable props to a Client Component (strings, numbers, plain objects, JSX, Server Actions). It CANNOT pass regular functions or class instances — they are not serializable and the build errors.
// WRONG — passing a function from Server → Client
<ClientChild onClick={() => doStuff()} /> // build error
// RIGHT — pass a Server Action (special-cased)
import { doStuff } from './actions';
<ClientChild action={doStuff} />
Pitfall — global CSS only in root layout
You can import globals.css ONLY from app/layout.tsx (the root). Importing from any other component fails the build. Use CSS Modules or Tailwind for component-scoped styles.
Pitfall — revalidatePath does not invalidate the cache, it queues it
revalidatePath marks the path as stale. The NEXT request gets fresh data, but the action itself does not wait. If you redirect right after, the user lands on the new page WITH fresh data on Vercel; locally you may need to also revalidatePath the destination.
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function action() {
await mutate();
revalidatePath('/todos'); // mark /todos stale
redirect('/todos'); // next render serves fresh data
}
Pitfall — caching mutates DB query plans
Pages with no dynamic API + cached fetches become statically rendered at build time. The HTML reflects the data at build time, not at request time. Add { cache: "no-store" } to anything personalized, or you will ship one user's data to everyone.
// WRONG — personalized data accidentally static
const me = await fetch('/api/me').then((r) => r.json()); // cached!
// Every visitor sees the first build user's data.
// RIGHT
const me = await fetch('/api/me', { cache: 'no-store' }).then((r) => r.json());
Pitfall — Server Component re-renders on every nav under it
A Server Component renders on the server for each request that hits a child route inside it — even nested layouts. If you do expensive work without caching, every nav pays. Memoize with fetch caching, unstable_cache, or React.cache.
import { cache } from 'react';
export const getUser = cache(async (id: string) =>
db.user.findUnique({ where: { id } }),
);
// safe to call from any number of Server Components — dedupes within one request
Pitfall — useSearchParams without Suspense breaks the build
During static generation Next cannot know the query string, so a useSearchParams not wrapped in <Suspense> throws "missing suspense boundary" at build time. Wrap only the reading component, not the whole page.
// WRONG — build error
export default function Page() { return <UsesSearchParams />; }
// RIGHT
import { Suspense } from 'react';
export default function Page() {
return <Suspense fallback={null}><UsesSearchParams /></Suspense>;
}
Pitfall — importing next/router in the App Router
next/router and useRouter from it belong to the Pages Router. In app/ they throw "NextRouter was not mounted". Always import navigation hooks from next/navigation instead.
// WRONG
import { useRouter } from 'next/router'; // Pages Router only
// RIGHT (App Router)
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
Pitfall — try/catch swallows redirect() and notFound()
redirect() and notFound() work by throwing a special error the framework catches. A broad try/catch around them eats that control-flow signal, so the redirect silently never happens. Call them outside the try, or rethrow.
// WRONG
try {
const u = await getUser();
if (!u) redirect('/login'); // caught and swallowed below
} catch (e) { console.error(e); }
// RIGHT — call after the try, or rethrow framework errors
const u = await getUser();
if (!u) redirect('/login');
Pitfall — Server Action return value must be serializable
Whatever a Server Action returns is sent back to the client, so it must be serializable — plain objects, strings, numbers, dates, arrays. Returning a class instance, a function, or a DB cursor fails. Map to a plain shape first.
// WRONG — returns a Prisma model instance with methods
export async function getTodo(id: string) { return db.todo.findUnique({ where: { id } }); }
// RIGHT — return a plain object
export async function getTodo(id: string) {
const t = await db.todo.findUnique({ where: { id } });
return t ? { id: t.id, title: t.title, done: t.done } : null;
}
Pitfall — cookies()/headers() make a route dynamic
Reading cookies(), headers(), draftMode(), or searchParams opts the whole route out of static rendering — it becomes server-rendered per request. If you expected a static page and lost it, an accidental cookies() read deep in the tree is the usual culprit.
// this single line forces the whole route dynamic
import { cookies } from 'next/headers';
const theme = (await cookies()).get('theme')?.value;
// if you only need it client-side, read document.cookie in a Client Component instead
Pitfall — environment-specific code in shared modules
A module imported by both Server and Client Components must not reference window at the top level (crashes on the server) or process.env secrets (leaks to the client). Split the env-specific bits behind server-only / client-only.
// WRONG — top-level window in a shared util
export const origin = window.location.origin; // crashes on the server
// RIGHT — defer to call time, guard the environment
export const getOrigin = () =>
typeof window === 'undefined' ? process.env.SITE_URL! : window.location.origin;
Independent awaits one after another run serially, so the total time is the sum, not the max. If request B does not need A's result, fire both with Promise.all. Only chain when there is a real data dependency.
// WRONG — waterfall, ~2x slower
const user = await getUser(id);
const posts = await getPosts(id); // does not depend on user
// RIGHT — parallel
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
Pitfall — Date and number formatting hydration drift
toLocaleString and Intl format using the runtime locale and timezone. The server (often UTC) and the user's browser can disagree, producing a hydration mismatch. Format on the client in an effect, or pass an explicit locale and timeZone.
// RIGHT — pin locale + timeZone so server and client agree
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
dateStyle: 'medium',
});
return <time>{fmt.format(new Date(post.createdAt))}</time>;
Pitfall — large client bundle from a barrel import
Importing a single icon from a giant index barrel (import { Icon } from "lib") can pull the whole package into the client bundle. Import the deep path directly, or use optimizePackageImports to let Next tree-shake the barrel.
// next.config.ts
export default {
experimental: { optimizePackageImports: ['lucide-react', '@mui/icons-material'] },
};
// now: import { Camera } from 'lucide-react' only ships Camera
Pitfall — mutating data in a Server Component render
A Server Component render can run multiple times (and is cached), so it must be side-effect free. Writing to the DB, sending email, or POSTing during render is a bug — those belong in Server Actions or route handlers triggered by user intent.
// WRONG — write during render, runs on every (cached) render
export default async function Page() {
await db.view.create({ data: { at: new Date() } }); // side effect!
return <Article />;
}
// RIGHT — log after the response with after(), or in a route handler
What this tool does
Searchable Next.js 15 cheat sheet built around the App
Router and the day-to-day patterns that actually show up
in production code. Eighty-plus entries, each with a
working code snippet, a short bilingual EN/ZH note, and —
when it matters — the exact pitfall that bites real teams.
App Router primitives (layout.tsx, page.tsx, loading.tsx,
error.tsx, not-found.tsx, template.tsx). Every routing
flavor: dynamic [slug], catch-all [...slug], optional
catch-all [[...slug]], route groups (folder), parallel
@slot, intercepting (.)folder. The Server / Client
boundary and pushing "use client" down to keep the JS
bundle small. Data fetching with the new Next 15 defaults
(fetch no longer caches by default — opt back in with
force-cache / revalidate / tags), generateStaticParams,
unstable_cache, cookies() / headers() as Promises,
parallel fetching with Promise.all. Server Actions
end-to-end — "use server" file-level vs inline, form
action prop, useActionState, useFormStatus,
revalidatePath / revalidateTag, Zod input validation.
Streaming with Suspense. Metadata API: generateMetadata,
title templates, opengraph-image.tsx, sitemap.ts,
robots.ts. next/image (priority, blur, fill,
remotePatterns) and next/font (google + local
self-hosted). Edge middleware with matcher patterns.
Route handlers (route.ts), runtime config, streaming
responses. Env vars (NEXT_PUBLIC_ vs server-only, load
order, build-time bake-in). Deploy paths (Vercel
zero-config, standalone for Docker, per-route edge
runtime). Plus a dedicated Pitfalls section: Client
Components cannot be async, Server Actions must come
from a form or import, hydration mismatch, "use server"
/ "use client" must be top-of-file, params / searchParams
are Promises in Next 15, fetch default flip, useFormStatus
parent trap, middleware Node API limits, function-prop
serialization, global CSS only in root layout,
revalidatePath queuing, accidentally-static personalized
pages. Pure client-side, no upload, no tracking.
Tool details
Input
Files + Text
The page exposes text boxes, numeric controls, file pickers, or structured inputs depending on the tool.
Output
Live result + Copy + Preview
The result area focuses on usable output, with copy, download, or preview actions when supported.
Privacy
May use a live lookup
A network call is detected in the component, so redact sensitive data when appropriate.
Save / share
Local preference storage
Preferences, history, or drafts are saved in this browser without an account.
Performance budget
Initial JS <= 30 KB
No WASM budget is declared, keeping the tool quick to open on mobile.
Best fit
Developer & DevOps · Developer
Category and role tags drive related tools, internal links, and quick fit checks.
How to use
1
1. Input
Paste or drop your content into the tool panel.
2
2. Process
Click the button. All processing is local in your browser.
3
3. Copy / Download
Copy the result or download to disk in one click.
How Next.js Cheatsheet fits into your work
Use it in the small gaps between coding, reviewing, debugging, and shipping.
Developer jobs
Formatting, validating, shrinking, or inspecting code-adjacent text.
Preparing snippets for documentation, tickets, commits, or handoff.
Checking a small payload quickly without switching tools.
Developer checks
Run irreversible transforms like minify or obfuscate on a copy.
Keep secrets out of pasted snippets unless the tool explicitly stays local.
Use your normal tests or linter before shipping transformed code.
Good next steps
These links move the current task into a more complete workflow.
Upgrading a Next 14 app to 15 without nuking your database
You bump to Next 15, deploy, and your Postgres connection
count quadruples within ten minutes. The culprit is the
fetch default flip: every fetch() that was silently
force-cache is now no-store. Pull up the data-fetching tab,
grep your repo for fetch( calls hitting your own API, and
add { cache: "force-cache" } or { next: { revalidate: 60 } }
to the ones that were meant to be static. Five minutes of
annotation, not a rollback.
Wiring a delete-comment button that works without JavaScript
A junior on your team writes a Server Action but calls it
with fetch("/api/...") and it 404s. The cheat sheet's
Server Actions tab shows the two valid wirings: import the
action into a Client Component, or hand it to a form via the
action prop. You pick the form path so the button still
works before hydration, add a useFormStatus pending spinner
in a child component, and revalidatePath("/post/[id]") so
the comment vanishes on submit.
Killing a hydration mismatch warning before a demo
Fifteen minutes before a client demo, the console screams
"hydration failed" and a timestamp flickers. You open the
hydration FAQ, spot that you rendered new Date() directly in
JSX, and apply fix 3: move the clock into a Client Component
that returns null until a mounted flag flips in useEffect.
One file, eight lines, warning gone, no suppressHydrationWarning
sprayed across the tree.
Onboarding a contractor who only knows the pages router
A contractor joins and keeps reaching for getServerSideProps
and pages/api. Instead of a 40-minute call, you send the
App Router sections: layout.tsx versus _app, route handlers
versus pages/api, Server Components versus getStaticProps.
They search "params" and immediately learn params is now a
Promise you await, which saves the afternoon they would have
lost to a TypeScript error they did not understand.
Common pitfalls
Putting "use server" under an import line — it silently becomes a no-op; keep the directive on the first non-comment line, before every import.
Calling useFormStatus inside the same component that owns the form — it always returns pending false; read it from a child component nested under the form.
Forgetting to await params in Next 15 — params and searchParams are now Promises, so const { slug } = await params, not const { slug } = params.
Privacy
This cheat sheet is a single static page. Your search queries and
anything you type stay in your browser and filter an in-memory array
of snippets; nothing is sent over the network, nothing lands in the
URL, and no code is executed. It runs fine behind a corporate proxy
or on an air-gapped machine.
FAQ
Related tools
Hand-picked utilities that pair well with this one.