Skip to main content

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
  • Category Developer & DevOps
  • Best for Formatting, 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.

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

app/page.tsx — route segment page

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.

// app/about/page.tsx → /about
export default function AboutPage() {
  return <h1>About us</h1>;
}

loading.tsx — streamed Suspense fallback

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.

// app/dashboard/loading.tsx
export default function Loading() {
  return <div className="skeleton h-40 w-full" />;
}

error.tsx — segment error boundary

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.

// app/dashboard/error.tsx
'use client';
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <p>Something broke: {error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

not-found.tsx — segment 404

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.

// app/blog/[slug]/page.tsx → /blog/hello
export default async function Post({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

Catch-all [...slug]

Three dots before the name match one or more path segments and pass them as an array. /docs/a/b/c → params.slug = ["a","b","c"].

// app/docs/[...slug]/page.tsx
export default async function Doc({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  return <pre>{slug.join('/')}</pre>;
}

Optional catch-all [[...slug]]

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.

// app/docs/[[...slug]]/page.tsx
export default async function Doc({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  return <pre>{slug ? slug.join('/') : 'home'}</pre>;
}

Route groups (folder)

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.

app/
  (marketing)/
    layout.tsx         // shared marketing chrome
    about/page.tsx     → /about
    pricing/page.tsx   → /pricing
  (app)/
    layout.tsx         // shared app chrome
    dashboard/page.tsx → /dashboard

Parallel routes @slot

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.

// app/search/page.tsx → /search?q=foo
export default async function Search({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  return <p>Searching for {q ?? 'nothing'}</p>;
}

<Link> for client-side navigation

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.

'use client';
import { usePathname } from 'next/navigation';

export function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
  const pathname = usePathname();
  const active = pathname === href;
  return (
    <Link href={href} aria-current={active ? 'page' : undefined}>
      {children}
    </Link>
  );
}

useSearchParams — read query string (client)

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.

'use client';
import { useParams } from 'next/navigation';

export function PostTag() {
  const { slug } = useParams<{ slug: string }>();
  return <code>{slug}</code>;
}

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.

'use client';
import { useRouter } from 'next/navigation';

export function RefreshButton() {
  const router = useRouter();
  return <button onClick={() => router.refresh()}>Refresh</button>;
}

permanentRedirect — 308 instead of 307

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.

<Link href="/feed?tab=top" scroll={false}>Top</Link>

// or programmatically
router.push('/feed?tab=top', { scroll: false });
Server / Client (12)

Server Components are the default

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.

'use client';
import { useState } from 'react';

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Server Component → Client Component composition

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.

export default async function Recipes() {
  const recipes = await fetch('https://api.example.com/recipes').then((r) => r.json());
  return <RecipeList recipes={recipes} />;
}

Client Components CANNOT be async

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.

// lib/analytics.ts
import 'client-only';

export function track(event: string) {
  window.gtag?.('event', event);
}

Context providers need "use client"

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.

const balance = await fetch('/api/me/balance', {
  cache: 'no-store',
}).then((r) => r.json());

next: { tags: [...] } + revalidateTag

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.

// app/blog/page.tsx
export const dynamic = 'force-static';
export const revalidate = 3600;

export const dynamic = "force-dynamic"

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.

export const dynamic = 'force-dynamic';
export const revalidate = 0;

generateStaticParams — pre-render dynamic paths

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.

import { unstable_cache } from 'next/cache';

export const getPostsBySlug = unstable_cache(
  async (slug: string) => db.post.findMany({ where: { slug } }),
  ['posts-by-slug'],
  { revalidate: 60, tags: ['posts'] },
);

cookies() / headers() — read request data

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.

const [user, posts, notifications] = await Promise.all([
  getUser(id),
  getPosts(id),
  getNotifications(id),
]);

export const revalidate — segment-level ISR

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.

// app/shop/[id]/page.tsx
export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }];
}
export const dynamicParams = false; // /shop/3 → 404

draftMode() — toggle preview rendering

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.

import { draftMode } from 'next/headers';

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { isEnabled } = await draftMode();
  const { slug } = await params;
  const post = await getPost(slug, { preview: isEnabled });
  return <Article post={post} />;
}

React cache() to dedupe non-fetch reads

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.

import { createTodo } from './actions';

export default function NewTodoForm() {
  return (
    <form action={createTodo}>
      <input name="title" required />
      <button type="submit">Add</button>
    </form>
  );
}

useActionState — handle action result

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.

'use client';
import { useActionState } from 'react';
import { createTodo } from './actions';

export function Form() {
  const [state, action, pending] = useActionState(createTodo, { error: null });
  return (
    <form action={action}>
      <input name="title" />
      {state.error && <p className="text-red-600">{state.error}</p>}
      <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>
    </form>
  );
}

useFormStatus — read pending state

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.

'use server';
import { z } from 'zod';
const Schema = z.object({ title: z.string().min(1).max(120) });

export async function createTodo(formData: FormData) {
  const parsed = Schema.safeParse({ title: formData.get('title') });
  if (!parsed.success) return { error: 'Invalid title' };
  await db.todo.create({ data: parsed.data });
}

bind() to pass extra arguments to an action

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.

'use client';
import { useOptimistic } from 'react';

export function Likes({ count, like }: { count: number; like: () => Promise<void> }) {
  const [optimistic, addOptimistic] = useOptimistic(count, (c) => c + 1);
  return (
    <form action={async () => { addOptimistic(null); await like(); }}>
      <button>♥ {optimistic}</button>
    </form>
  );
}

Returning typed errors from useActionState

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.

'use server';
type State = { errors?: { email?: string }; ok?: boolean };
export async function subscribe(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email'));
  if (!email.includes('@')) return { errors: { email: 'Enter a valid email' } };
  await db.subscriber.create({ data: { email } });
  return { ok: true };
}

Server Actions are POST-only and CSRF-guarded

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.

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <SlowList />
      </Suspense>
    </div>
  );
}

Multiple parallel Suspense boundaries

Each boundary streams in independently — the slowest does NOT block the rest. Wrap each independent data source separately to maximize parallelism.

<>
  <Suspense fallback={<Sk1 />}><Feed /></Suspense>
  <Suspense fallback={<Sk2 />}><Sidebar /></Suspense>
  <Suspense fallback={<Sk3 />}><Comments /></Suspense>
</>

loading.tsx is a Suspense boundary

A loading.tsx co-located with a page IS the Suspense fallback for that segment's page. Same effect as wrapping <Suspense> manually around <Page />.

app/dashboard/
  page.tsx        // async, slow
  loading.tsx     // shown until page resolves

preload pattern to start fetches early

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.

import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post.title, description: post.excerpt };
}

Title template — DRY child titles

Set metadata.title to { template: "%s · Acme", default: "Acme" } in the root layout. Child pages that set a string title get "Whatever · Acme" automatically.

// app/layout.tsx
export const metadata = {
  title: { template: '%s · Acme', default: 'Acme' },
};

// app/about/page.tsx
export const metadata = { title: 'About' };  // → "About · Acme"

opengraph-image.tsx — generated OG image

Drop an opengraph-image.tsx in any segment to generate an OpenGraph image with ImageResponse and JSX. Twitter card variant is twitter-image.tsx.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export default async function OG() {
  return new ImageResponse(
    <div style={{ fontSize: 96, background: '#000', color: '#fff' }}>Hello</div>,
    size,
  );
}

sitemap.ts and robots.ts

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.

// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany();
  return posts.map((p) => ({
    url: 'https://acme.com/blog/' + p.slug,
    lastModified: p.updatedAt,
  }));
}

metadataBase — resolve relative OG URLs

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.

// app/layout.tsx
export const metadata = {
  metadataBase: new URL('https://acme.com'),
  openGraph: { images: ['/og.png'] }, // → https://acme.com/og.png
};

alternates.canonical — set the canonical URL

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.

export const metadata = {
  alternates: {
    canonical: 'https://acme.com/blog/hello',
    languages: { 'en-US': '/en/blog/hello', 'zh-CN': '/zh/blog/hello' },
  },
};

viewport export — separate from metadata

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.

export const metadata = {
  icons: { icon: '/favicon.ico', apple: '/apple-icon.png' },
  manifest: '/manifest.webmanifest',
};
// or convention: app/icon.png, app/apple-icon.png, app/manifest.ts
next/image (9)

next/image — basic usage

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).

import Image from 'next/image';

<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

placeholder="blur" — LQIP

Set placeholder="blur" and either blurDataURL for remote images or rely on the auto-generated one for local imports. Smooth, no flash of empty box.

import Image from 'next/image';
import hero from './hero.jpg';   // auto blurDataURL

<Image src={hero} alt="Hero" placeholder="blur" />

priority — load above-the-fold first

Set priority on the LCP image. Disables lazy-loading and adds a fetchpriority="high" hint. Use on exactly ONE image per page — the hero.

<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

fill — fill a positioned parent

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.

<div style={{ position: 'relative', width: '100%', height: 400 }}>
  <Image src="/banner.jpg" alt="" fill style={{ objectFit: 'cover' }} />
</div>

images.remotePatterns config

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.

// next.config.ts
export default {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.acme.com', pathname: '/img/**' },
    ],
  },
};

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.

<Image
  src="/cover.jpg"
  alt=""
  fill
  sizes="(max-width: 768px) 100vw, 50vw"
  style={{ objectFit: 'cover' }}
/>

quality prop — trade size for sharpness

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.

// next.config.ts
export default { images: { qualities: [50, 75, 90] } };

// usage
<Image src="/thumb.jpg" alt="" width={120} height={120} quality={50} />

unoptimized — skip the optimizer

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.

<Image src="/loader.gif" alt="" width={48} height={48} unoptimized />

// or globally
// next.config.ts → { images: { unoptimized: true } }

Custom loader for an external image CDN

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.

'use client';
const cloudinary = ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
  `https://res.cloudinary.com/acme/image/upload/w_${width},q_${quality ?? 'auto'}/${src}`;

<Image loader={cloudinary} src="hero.jpg" alt="" width={800} height={400} />
next/font (4)

next/font/google — self-hosted Google Font

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.

import localFont from 'next/font/local';
const display = localFont({
  src: './fonts/Display-Regular.woff2',
  display: 'swap',
  variable: '--font-display',
});

Font CSS variable + Tailwind

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().

const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-experiment', 'B');
return NextResponse.rewrite(new URL('/new-home', req.url), {
  request: { headers: requestHeaders },
});

Reading and setting cookies in middleware

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.

// next.config.ts
export default { experimental: { nodeMiddleware: true } };

// middleware.ts
export const config = { runtime: 'nodejs' };
Route handlers (8)

route.ts — App Router API endpoint

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.

// app/api/posts/route.ts
export const dynamic = 'force-static';
export const revalidate = 60;

export async function GET() {
  const posts = await db.post.findMany();
  return Response.json(posts);
}

Streaming responses from route handlers

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.

// app/api/search/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get('q') ?? '';
  const results = await db.item.findMany({ where: { name: { contains: q } } });
  return Response.json(results);
}

CORS headers in a route handler

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.

// app/api/login/route.ts
import { cookies } from 'next/headers';

export async function POST(req: Request) {
  const { token } = await req.json();
  (await cookies()).set('session', token, { httpOnly: true, secure: true, path: '/' });
  return Response.json({ ok: true });
}

Returning a custom status and redirect

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.

// next.config.ts
export default { output: 'standalone' };

// Dockerfile (excerpt)
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Edge runtime — per-route

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).

// app/api/geo/route.ts
export const runtime = 'edge';

export async function GET(req: Request) {
  return Response.json({ country: req.headers.get('x-vercel-ip-country') });
}

next build — what it actually does

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.

pnpm next build
# Route (app)                              Size     First Load JS
# ┌ ○ /                                    1.2 kB        82 kB
# ├ ○ /about                               0.5 kB        81 kB
# └ λ /dashboard                           5.0 kB        90 kB
# ○ (Static)  prerendered as static content
# λ (Dynamic) server-rendered on demand

next start — run the production server

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.

// next.config.ts
export default {
  async headers() {
    return [{
      source: '/fonts/:path*',
      headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
    }];
  },
};

Redirects and rewrites in next.config

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.

// next.config.ts
export default {
  async redirects() {
    return [{ source: '/old', destination: '/new', permanent: true }];
  },
  async rewrites() {
    return [{ source: '/api/v1/:path*', destination: 'https://api.acme.com/:path*' }];
  },
};

Instrumentation hook — register at server boot

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().

// WRONG (Next 14 style)
export default function Page({ params }: { params: { id: string } }) {
  return <p>{params.id}</p>;
}

// RIGHT (Next 15)
export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <p>{id}</p>;
}

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.

// app/layout.tsx — OK
import './globals.css';

// app/feed/page.tsx — build error
import '../globals.css';   // ❌

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;

Pitfall — sequential awaits create request waterfalls

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.

  1. 1 JSON Formatter & Validator Format, validate, and minify JSON instantly — right in your browser. Open
  2. 2 React Hooks Cheatsheet React hooks cheat sheet — all 17 built-in hooks (useState / useEffect / useMemo / useTransition / useFormStatus...) with real examples and pitfalls. Open
  3. 3 Vue 3 Cheatsheet Vue 3 cheat sheet — Composition API, reactivity, components, directives, Pinia, with side-by-side Options API comparison. Open

Real-world use cases

  • 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

Tool combos

Folks in your role tend to reach for these alongside this tool.

Made by Toolora · 100% client-side · Updated 2026-06-14