../

Next.js

Full-stack React with the Next.js 16 App Router (16.3, React 19.2): file-based routing, Server and Client Components, Cache Components ("use cache"), Server Functions, Route Handlers, proxy.ts and deployment. Plain React lives in React and React Hooks.

Setup & CLI

bun create next-app my-app --yes  # TS, Tailwind, ESLint
cd my-app
bun dev                   # next dev (Turbopack) on :3000
bun --bun next dev        # same, but on the Bun runtime
bun run build && bun run start

create-next-app flags: --api (route handlers only), --src-dir, --empty, --biome, --no-linter, --react-compiler, --use-bun/--use-pnpm, --no-agents-md. npm: npx create-next-app@latest.

CommandDoes
next devdev server; -p 4000, -H 127.0.0.1, --experimental-https, --webpack
next buildproduction build; --webpack, --debug-prerender, -d (verbose)
next startserve the build; -p, --keepAliveTimeout
next typegengenerate PageProps/LayoutProps/RouteContext types without a build
next infosystem report for bug reports
next upgradebump Next.js; --revision canary
next experimental-analyzeTurbopack bundle analysis
bunx @next/codemod@canary upgrade latestupgrade plus codemods

What changed in 16

ChangeNow
BundlerTurbopack is the default for dev and build; --webpack opts out
Request APIsparams, searchParams, cookies(), headers(), draftMode() are async only
middleware.tsrenamed proxy.ts, export proxy; Node.js runtime only
PPR / dynamicIO / useCache flagsreplaced by cacheComponents: true
revalidateTag(tag)needs a profile: revalidateTag(tag, "max"); new updateTag, refresh
cacheLife, cacheTagstable, no unstable_ prefix
Parallel routesevery @slot needs a default.tsx
next lintremoved; run ESLint or Biome directly
next/imagepreload replaces priority; qualities defaults to [75]
error.tsxretry() prop (re-fetch and re-render) next to reset()
MinimumsNode 20.9, TypeScript 5.1; AMP and publicRuntimeConfig removed

App Router file conventions

Folders are URL segments. A folder becomes routable only when it has a page or route file, so other files can sit next to them safely.

app/ router
my-app/app/layout.tsx            # root layout: html + bodypage.tsx              # /loading.tsx           # Suspense fallback for segmenterror.tsx             # error boundary ("use client")global-error.tsx      # replaces root layout on crashnot-found.tsx         # notFound() and unmatched URLsglobals.cssfavicon.icoopengraph-image.tsx   # generated og:imagesitemap.ts            # /sitemap.xmlrobots.ts             # /robots.txt(marketing)/          # route group: not in the URLlayout.tsx        # layout only for this groupabout/page.tsx      # /aboutblog/page.tsx          # /blog_components/      # private folder: never routedpost-card.tsx[slug]/page.tsx      # /blog/:slugopengraph-image.tsxshop/[...slug]/page.tsx      # /shop/a, /shop/a/bdocs/[[...slug]]/page.tsx      # /docs, /docs/a/bdashboard/layout.tsx        # gets children, team, statspage.tsx@team/page.tsxdefault.tsx   # required fallback per slot@stats/page.tsxdefault.tsxphotos/[id]/page.tsx      # full page on hard navigation@modal/               # slot: root layout props.modaldefault.tsx       # returns null(.)photos/[id]/page.tsx  # intercept: modal on soft navapi/posts/route.ts      # GET/POST /api/posts[id]/route.ts  # /api/posts/:idpublic/                   # served from /proxy.ts                  # runs before routinginstrumentation.ts        # register() on server startnext.config.ts.env.localtsconfig.json
FileRole
layout.tsxshared UI; stays mounted across navigations; root one must render <html> and <body>
page.tsxthe route's UI; makes the segment public
template.tsxlike a layout, but remounts on every navigation
loading.tsxwraps the page in <Suspense> with this fallback
error.tsxclient error boundary for the segment below its layout
global-error.tsxroot-level boundary; renders its own <html>
not-found.tsxUI for notFound(); the root one also handles unmatched URLs
forbidden.tsx / unauthorized.tsxUI for forbidden()/unauthorized() (experimental.authInterrupts)
default.tsxfallback for a parallel slot with no match
route.tsHTTP handler; cannot share a segment with page.tsx

Render nesting per segment: layout → template → error → loading → not-found → page.

Folder nameMatchesparams
blog/[slug]/blog/a{ slug: string }
shop/[...slug]/shop/a, /shop/a/b (not /shop){ slug: string[] }
docs/[[...slug]]/docs, /docs/a/b{ slug?: string[] }
(group)adds nothing to the URL; groups layoutsnone
_foldernever routednone
%5Fnamea real segment starting with _none
@slotparallel route, passed to the parent layout as a propnone
(.)x / (..)x / (..)(..)x / (...)xintercept x from the same level / parent / two up / rootnone

Intercepting counts route segments, not folders, so @slot and (group) don't count.

Pages, layouts & navigation

next dev, next build and next typegen generate the global helpers PageProps<"/route">, LayoutProps<"/route"> and RouteContext<"/route"> from your folders.

app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
 
export const metadata: Metadata = {
  title: { template: "%s | Acme", default: "Acme" },
};
 
export default function RootLayout({
  children,
}: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost, getSlugs } from "@/lib/posts";
 
// prerender these at build; others render on first visit
export async function generateStaticParams() {
  const slugs = await getSlugs();
  return slugs.map((slug) => ({ slug }));
}
 
export default async function Page(
  props: PageProps<"/blog/[slug]">,
) {
  const { slug } = await props.params;   // a Promise now
  const post = await getPost(slug);
  if (!post) notFound();                 // throws
  return <article>{post.title}</article>;
}

searchParams is a Promise<Record<string, string | string[] | undefined>> and only exists on pages. Reading it makes that part request-time.

APIImportWhereUse
<Link href prefetch>next/linkbothclient navigation; prefetches links in the viewport
useRouter()next/navigationclientpush, replace, refresh, back, prefetch
usePathname(), useSearchParams(), useParams()next/navigationclientread the URL (useSearchParams wants a <Suspense> above it)
useSelectedLayoutSegment(s)()next/navigationclientactive-link state in a layout
useLinkStatus()next/linkclientpending flag inside a <Link>
redirect(url) / permanentRedirect(url)next/navigationserver, actionsthrows; 307/308 (303 from an action)
notFound()next/navigationserverrenders the nearest not-found.tsx
forbidden() / unauthorized()next/navigationserver403/401 UI (experimental)

Errors

app/dashboard/error.tsx
"use client";
 
export default function ErrorPage({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  return (
    <div>
      <p>Something broke: {error.message}</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  );
}
  • error.tsx does not catch errors from the layout.tsx in the same segment; put it one level up.
  • In production, server error messages are replaced by a generic one plus digest (match it in logs).
  • catchError(Fallback) from next/error builds a component-level boundary that lets redirect()/notFound() through.
  • Return expected errors (validation, 4xx) as values; throw only for bugs.

Server & Client Components

Components are Server Components unless a module starts with "use client". That directive marks a boundary: the module and everything it imports ship to the browser.

Server (default)Client ("use client")
Runsserver, at build or request timeserver (SSR HTML) and browser (hydration)
async componentyesno: unwrap promises with use()
State, effects, refsnoyes
Event handlers, browser APIsnoyes
DB, filesystem, secretsyesno; only NEXT_PUBLIC_* env vars
JS sent to the browsernonethe component and its imports
Props it receivesanythingserializable values, JSX, Server Functions
  • Push "use client" down to leaves (a button, not a page).
  • Pass Server Components into Client ones as children or other JSX props.
  • Wrap context providers in a Client Component that renders {children}.
  • import "server-only" (package server-only) makes a module fail the build if a client imports it.
app/page.tsx
import { Suspense } from "react";
import { getStats } from "@/lib/stats";
import { Stats } from "./stats";
 
export default function Page() {
  const stats = getStats();          // start, don't await
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Stats stats={stats} />
    </Suspense>
  );
}
app/stats.tsx
"use client";
import { use } from "react";
 
export function Stats(props: {
  stats: Promise<{ views: number }>;
}) {
  const { views } = use(props.stats);  // suspends
  return <p>{views} views</p>;
}

Data fetching & caching

With cacheComponents: true everything is dynamic by default and you opt in to caching with "use cache". Without the flag, the previous model (route segment revalidate/dynamic, fetch next.revalidate, unstable_cache) still applies.

next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  cacheComponents: true,
};
 
export default nextConfig;
DirectivePut at the top ofEffect
"use cache"an async function, component or filecaches the return value; arguments and closures form the key
"use cache: remote"samestores entries in a shared cache handler (Redis, KV) instead of per-instance memory
"use cache: private"samemay read cookies()/headers(); cached only in the browser
"use server"an async function or fileServer Function callable from the client
"use client"a fileClient Component boundary
lib/data.ts
import { cacheLife, cacheTag } from "next/cache";
import { cache } from "react";
import { db } from "@/lib/db";
 
export async function getPosts() {
  "use cache";
  cacheLife("hours");            // always pick a lifetime
  cacheTag("posts");             // on-demand invalidation
  return db.post.findMany();
}
 
// per-request dedupe only (not a cross-request cache)
export const getUser = cache(async (id: string) =>
  db.user.find(id),
);
  • A cached scope cannot call cookies(), headers() or read searchParams. Read them outside and pass the values in as arguments.
  • Arguments and return values must be serializable. JSX children passes through without becoming part of the key.
  • Default storage is an in-memory LRU per instance. On serverless it rarely survives between requests, and no entry survives a new deployment.
  • Independent fetches: await Promise.all([a(), b()]). fetch itself is not cached.

cacheLife profiles

Profilestale (client)revalidate (server, background)expire (hard)
default5 min15 minnever
seconds30 s1 s1 min
minutes5 min1 min1 h
hours5 min1 h1 day
days5 min1 day1 week
weeks5 min1 week30 days
max5 min30 days1 year

Custom: cacheLife({ stale: 60, revalidate: 300, expire: 3600 }) (seconds), or name a profile under cacheLife in next.config.ts. A revalidate of 0 or an expire under 5 min makes the entry a request-time hole instead of part of the prerender.

Invalidation

CallWhereSemantics
updateTag(tag)Server Actions onlyexpire now; this response already shows fresh data (read-your-writes)
revalidateTag(tag, "max")actions, Route Handlersstale-while-revalidate: next visit gets stale, refresh runs behind
revalidateTag(tag, { expire: 0 })actions, Route Handlersexpire now, e.g. from a webhook
revalidatePath("/blog/1")actions, Route Handlersone literal path; for a pattern pass a type: ("/blog/[slug]", "page"), "layout" for the subtree
refresh()Server Actionsre-render the current route without touching caches

All come from next/cache. Tags are case-sensitive, at most 256 chars. fetch(url, { next: { tags } }) also tags a fetch.

Rendering

At build time Next renders each route and keeps whatever finishes as the static shell (HTML plus RSC payload, served from the CDN). Holes inside <Suspense> stream in at request time. This is Partial Prerendering (PPR), the default under Cache Components.

Code in the treeAt prerenderEnds up
Plain JSX, imports, sync fs reads, pure computationrunsstatic shell
"use cache" with a normal lifetimerunsstatic shell, revalidated per cacheLife
cookies(), headers(), searchParams, connection()stopsstreams behind <Suspense>
Uncached fetch/DB querystopsstreams behind <Suspense>
Math.random(), Date.now(), crypto.randomUUID()errorcall await connection() first, or cache it
params listed by generateStaticParamsrunsconcrete shell per param
unknown paramsstopsapp shell now, filled in and cached on first visit (ISR)

Request-time data read outside a <Suspense> boundary is an error (dev overlay, then the build). Fix it by wrapping in <Suspense> (or adding loading.tsx), caching it, or moving the read deeper.

app/shop/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { getPosts } from "@/lib/data";
 
export default function Page() {
  return (
    <>
      <h1>Shop</h1>                   {/* static */}
      <Posts />                       {/* cached */}
      <Suspense fallback={<p>…</p>}>
        <Cart />                      {/* per request */}
      </Suspense>
    </>
  );
}
 
async function Posts() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}
 
async function Cart() {
  const id = (await cookies()).get("cart")?.value;
  return <p>Cart {id ?? "empty"}</p>;
}
Request APIImportNotes
await cookies()next/headers.get, .getAll, .has; .set/.delete only in actions and Route Handlers
await headers()next/headersread-only Headers
await draftMode()next/headersisEnabled, enable(), disable()
await connection()next/server"render per request from here on"
after(fn)next/serverrun work after the response is sent (logging, analytics)

export const instant = true on a page or layout asks the dev overlay to flag anything that would block a client navigation into it. Bots get fully rendered HTML instead of the shell plus stream.

Mutations

Server Functions are async functions marked "use server". Pass one to <form action>, <button formAction>, or call it inside startTransition. Next sends it as a POST to the current page and returns the result plus the re-rendered route in one response.

app/posts/actions.ts
"use server";
import { redirect } from "next/navigation";
import { updateTag } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
 
export async function deletePost(id: string) {
  const session = await auth();          // always re-check
  if (!session) throw new Error("Unauthorized");
  await db.post.delete(id, session.userId);
  updateTag("posts");                    // before redirect
  redirect("/posts");                    // throws
}
app/posts/delete-button.tsx
import { deletePost } from "./actions";
 
export function DeleteButton({ id }: { id: string }) {
  // bind extra args; FormData would come last
  return (
    <form action={deletePost.bind(null, id)}>
      <button>Delete</button>
    </form>
  );
}
HookFromGives
useActionState(fn, init)react[state, formAction, pending]; fn(prev, formData)
useFormStatus()react-dompending, data for the enclosing <form>
useOptimistic(state, reducer)reacttemporary state while an action runs
useTransition()reactstartTransition(async () => await action()) outside forms
  • Every action is a public endpoint: authenticate, authorize and validate (Zod) inside it. proxy.ts coverage alone is not enough.
  • The client runs actions one at a time. For parallel work, do it inside one action.
  • Body limit is 1 MB (experimental.serverActions.bodySizeLimit). Origin is checked against Host (allowedOrigins for proxies).
  • Return plain, minimal objects. Return values are serialized to the client.

Route handlers

route.ts exports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS using Web Request/Response (see Fetch API). Other methods get a 405.

app/api/posts/[id]/route.ts
import type { NextRequest } from "next/server";
import { getPost } from "@/lib/posts";
 
export async function GET(
  req: NextRequest,
  ctx: RouteContext<"/api/posts/[id]">,
) {
  const { id } = await ctx.params;
  const fields = req.nextUrl.searchParams.get("fields");
  const post = await getPost(id);
  if (!post) {
    return Response.json({ error: "not found" }, {
      status: 404,
    });
  }
  return Response.json({ post, fields });
}
DetailBehavior
NextRequestadds nextUrl (parsed URL) and cookies to Request
NextResponseNextResponse.json, .redirect, .rewrite, .next(), response.cookies.set
Caching (Cache Components)GET is prerendered if it touches no request data or uncached I/O
"use cache"not allowed in the handler body; put it in a helper it calls
Streamingreturn new Response(readableStream) (see Streaming)
Segment configmaxDuration, preferredRegion; dynamic/revalidate exist only without Cache Components

Prefer a Server Function for mutations from your own UI, and a Route Handler for webhooks, public APIs, other clients and file downloads.

Proxy

proxy.ts (was middleware.ts) sits next to app/ and runs before routing, on the Node.js runtime. Use it for redirects, rewrites, headers, A/B buckets and cheap auth gates.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";
 
export function proxy(req: NextRequest) {
  if (req.nextUrl.pathname === "/old") {
    return NextResponse.redirect(new URL("/new", req.url));
  }
  const headers = new Headers(req.headers);
  headers.set("x-pathname", req.nextUrl.pathname);
  const res = NextResponse.next({ request: { headers } });
  res.headers.set("x-frame-options", "DENY");
  return res;
}
 
export const config = {
  // skip static assets and images
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
ReturnEffect
nothing / NextResponse.next()continue; can set request/response headers and cookies
NextResponse.redirect(url)307 to another URL
NextResponse.rewrite(url)serve another route, keep the URL
Response.json(...) / new Response()answer directly

Order: next.config headers → redirects → proxy → beforeFiles rewrites → files and static routes → afterFiles → dynamic routes → fallback. Matchers must be string literals. Server Functions are POSTs to the page route, so a matcher that skips a page also skips its actions.

Metadata & SEO

app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";
 
export async function generateMetadata(
  props: PageProps<"/blog/[slug]">,
): Promise<Metadata> {
  const { slug } = await props.params;
  const post = await getPost(slug);
  return {
    title: post?.title,          // fills the layout template
    description: post?.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { type: "article" },
  };
}

Export either metadata (static) or generateMetadata, from a layout or page, Server Components only. Set metadataBase: new URL("https://acme.dev") in the root layout so relative URLs resolve. Viewport and themeColor go in export const viewport / generateViewport.

File in a segmentProduces
favicon.ico, icon.png, apple-icon.pngicon <link> tags
icon.tsx, apple-icon.tsxgenerated icons (ImageResponse)
opengraph-image.png / .tsx, twitter-image.*og:image / twitter:image plus size and alt
sitemap.ts (returns MetadataRoute.Sitemap)/sitemap.xml; generateSitemaps to split
robots.ts (returns MetadataRoute.Robots)/robots.txt
manifest.ts (returns MetadataRoute.Manifest)/manifest.webmanifest
app/sitemap.ts
import type { MetadataRoute } from "next";
import { getSlugs } from "@/lib/posts";
 
export default async function sitemap():
  Promise<MetadataRoute.Sitemap> {
  const slugs = await getSlugs();
  return slugs.map((slug) => ({
    url: `https://acme.dev/blog/${slug}`,
    changeFrequency: "weekly",
  }));
}

Images & fonts

app/hero.tsx
import Image from "next/image";
import hero from "./hero.jpg";  // static: size + blur
 
export function Hero() {
  return (
    <>
      <Image
        src={hero}
        alt="Team"
        placeholder="blur"
        preload
      />
      <div style={{ position: "relative", height: 240 }}>
        <Image
          src="https://cdn.acme.dev/a.png"
          alt="Chart"
          fill
          sizes="(max-width: 768px) 100vw, 50vw"
        />
      </div>
    </>
  );
}
PropNotes
src, altrequired; remote URLs need images.remotePatterns
width, heightrequired for remote images unless fill
fillstretches to the positioned parent; pair with sizes
sizesmedia list for srcset; without it the browser assumes 100vw
preloadpreload the LCP image (replaces priority in 16)
qualitymust be listed in images.qualities (default [75])
placeholder"blur" (auto for static imports; else blurDataURL), "empty"
unoptimizedserve as-is
app/layout.tsx
import { Inter } from "next/font/google";
import localFont from "next/font/local";
 
const inter = Inter({
  subsets: ["latin"],
  variable: "--font-sans",
});
const mono = localFont({
  src: "./fonts/Mono.woff2",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: LayoutProps<"/">) {
  const fonts = `${inter.variable} ${mono.variable}`;
  return (
    <html lang="en" className={fonts}>
      <body>{children}</body>
    </html>
  );
}

next/font downloads Google fonts at build time and self-hosts them, with no request to Google at runtime. It also adds size-adjusted fallbacks to avoid layout shift. Options: weight, style, subsets, display ("swap" default), variable, preload, fallback.

Environment variables

Source (first match wins)Loaded when
process.env (shell, platform)always
.env.$(NODE_ENV).localalways
.env.localnot in test
.env.$(NODE_ENV)development / production / test
.envalways
  • Only NEXT_PUBLIC_* reach the browser, and they are inlined at build time. One Docker image promoted across environments keeps the build-time values.
  • Server-only vars are read at runtime. Call await connection() first if a prerendered component must see the runtime value.
  • Only literal process.env.NEXT_PUBLIC_X is replaced. process.env[name] and destructuring are not.
  • $OTHER inside .env values expands. Commit .env defaults, keep *.local out of git.
  • Outside Next (ORM configs, scripts): import { loadEnvConfig } from "@next/env".
lib/env.ts
import "server-only";
import { z } from "zod";
 
export const env = z
  .object({
    DATABASE_URL: z.url(),
    AUTH_SECRET: z.string().min(32),
  })
  .parse(process.env);

next.config

next.config.ts exports a NextConfig (or a function of phase).

OptionDoes
cacheComponents"use cache", PPR, dynamic-by-default model
cacheLifecustom named cache profiles
reactCompilerautomatic memoization (needs babel-plugin-react-compiler)
typedRoutestype-check <Link href> and router paths
output"standalone" (minimal server bundle) or "export" (static HTML)
imagesremotePatterns, localPatterns, qualities, formats, minimumCacheTTL (4 h default)
redirects, rewrites, headersasync functions returning rule arrays
basePath, assetPrefix, trailingSlashURL layout, CDN prefix
turbopackrules (loaders), resolveAlias, root
serverExternalPackageskeep packages out of the server bundle (native deps)
transpilePackagescompile workspace or ESM-only packages
experimental.serverActionsbodySizeLimit, allowedOrigins
deploymentIdversion-skew protection across rolling deploys
cacheHandlersbackends for "use cache: remote"
loggingfetches.fullUrl and friends in dev
typescript.ignoreBuildErrorsskip type errors in next build
poweredByHeaderfalse removes x-powered-by
next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  cacheComponents: true,
  typedRoutes: true,
  output: "standalone",
  images: {
    remotePatterns: [new URL("https://cdn.acme.dev/**")],
  },
  async redirects() {
    return [
      { source: "/docs", destination: "/guide",
        permanent: true },
    ];
  },
};
 
export default nextConfig;

Deploying

TargetHowSupports
Vercelpush to Git or vercel deployeverything, managed caching and CDN
Node servernext build && next starteverything
Dockeroutput: "standalone", node server.jseverything (see Recipes)
Static exportoutput: "export" → out/no proxy, Server Functions, ISR or request-time rendering
AdaptersadapterPath; verified: Vercel, Bunvaries; Cloudflare, Netlify ship their own

Self-hosting checklist:

  • Behind nginx, disable buffering (X-Accel-Buffering: no) or streaming and PPR stop working.
  • Several instances need the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (base64 AES key at build), a deploymentId, and a shared cacheHandlers store if "use cache" must be consistent.
  • Standalone output skips public/ and .next/static. Copy them in or serve them from a CDN.
  • PORT and HOSTNAME=0.0.0.0 configure server.js. Cache Components needs the Node.js runtime.

See Dockerfile and Docker.

Recipes

Server Action form with Zod and useActionState

Use for any form that needs field errors and a pending state without client fetch code.

app/signup/actions.ts
"use server";
import { z } from "zod";
import { updateTag } from "next/cache";
import { db } from "@/lib/db";
 
const Signup = z.object({
  email: z.email(), name: z.string().trim().min(2),
});
 
type Errors = { email?: string[]; name?: string[] };
export type SignupState = { errors?: Errors; ok?: boolean };
 
export async function signup(
  _prev: SignupState,
  form: FormData,
): Promise<SignupState> {
  const parsed = Signup.safeParse(Object.fromEntries(form));
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    return { errors: fieldErrors };
  }
  await db.user.create(parsed.data);
  updateTag("users");
  return { ok: true };
}
app/signup/form.tsx
"use client";
import { useActionState } from "react";
import { signup } from "./actions";
 
export function SignupForm() {
  const [state, action, pending] =
    useActionState(signup, {});
  return (
    <form action={action}>
      <input name="email" type="email" required />
      <p aria-live="polite">{state.errors?.email?.[0]}</p>
      <input name="name" required />
      <p aria-live="polite">{state.errors?.name?.[0]}</p>
      <button disabled={pending}>
        {pending ? "Saving…" : "Sign up"}
      </button>
      {state.ok && <p>Welcome aboard.</p>}
    </form>
  );
}

Cached data with tag revalidation

Use when data changes rarely and a CMS or webhook can tell you when.

lib/products.ts
import { cacheLife, cacheTag } from "next/cache";
 
export type Product = { id: string; name: string };
 
export async function getProduct(id: string) {
  "use cache";
  cacheLife("max");
  cacheTag("products", `product:${id}`);
  const res = await fetch(`https://api.acme.dev/p/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as Product;
}
app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
 
export async function POST(req: Request) {
  const secret = req.headers.get("x-webhook-secret");
  if (secret !== process.env.WEBHOOK_SECRET) {
    return new Response(null, { status: 401 });
  }
  const { id } = (await req.json()) as { id: string };
  revalidateTag(`product:${id}`, "max");  // SWR
  return Response.json({ revalidated: id });
}

JSON API route handler

Use for a small typed API consumed by other clients.

app/api/todos/route.ts
import type { NextRequest } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
 
const NewTodo = z.object({ title: z.string().min(1) });
 
export async function GET(req: NextRequest) {
  const q = req.nextUrl.searchParams.get("q") ?? "";
  const todos = await db.todo.search(q);
  return Response.json({ data: todos });
}
 
export async function POST(req: NextRequest) {
  const body: unknown = await req.json().catch(() => null);
  const parsed = NewTodo.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { error: z.flattenError(parsed.error).fieldErrors },
      { status: 400 },
    );
  }
  const todo = await db.todo.create(parsed.data);
  return Response.json({ data: todo }, { status: 201 });
}

Auth gate in proxy

Use to bounce signed-out users early. Still verify the session in pages and actions.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";
 
const PUBLIC = ["/login", "/signup"];
 
export function proxy(req: NextRequest) {
  const { pathname, search } = req.nextUrl;
  if (PUBLIC.some((p) => pathname.startsWith(p))) return;
  if (req.cookies.has("session")) return;
 
  const url = new URL("/login", req.url);
  url.searchParams.set("next", pathname + search);
  return NextResponse.redirect(url);
}
 
export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

Dynamic Open Graph image

Use to give every post a generated social card.

app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
 
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Blog post";
 
export default async function Image(props: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await props.params;
  const post = await getPost(slug);
  return new ImageResponse(
    <div style={{
      display: "flex", width: "100%", height: "100%",
      alignItems: "center", padding: 80, fontSize: 72,
      background: "#0b0b0f", color: "white",
    }}>
      {post?.title ?? "Acme blog"}
    </div>,
    size,
  );
}

Standalone Docker image

Use to self-host with a small image. Install with Bun, build and run on Node (Cache Components needs the Node runtime). Set output: "standalone" first.

Dockerfile
FROM oven/bun:1-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
 
FROM node:24-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx next build
 
FROM node:24-alpine AS run
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node \
  /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

References