Next.js in production
Use for any code in a Next.js 16 App Router app. Security-specific rules (auth in actions, CSP, headers, taint) are in nextjs-security; container rules in docker-nextjs. Claude Code's built-in next-best-practices skill covers general file conventions, metadata, image and font optimisation in depth; this skill only adds what's specific to how we build and ship.
Baseline: Next 16.4, React 19.3, Turbopack (default for dev and build), cacheComponents: true with partialPrefetching: true, output: "standalone", typedRoutes: true. The template's next.config.ts is the source of truth.
Rules
-
Server Components by default;
"use client"only for state, effects, browser APIs or event handlers. Put the directive on the smallest leaf that needs it. Don't add it because a file imports nuvui: nuvui's stateful parts carry their own directive (see thenuvuiskill,rules/server-components.md).// Wrong: whole page is client because of one handler "use client"; export default function Page() { … <Select onValueChange={…} /> … } // Right: the page stays on the server, the handler lives in a leaf export default async function Page() { const people = await getPeople(); return <PeopleFilter people={people} />; // people-filter.tsx has "use client" }Props crossing into a client component must be serialisable: no functions (except Server Actions), no class instances.
-
Fetch on the server, in the component that needs the data. No
useEffectfetching, no route handler just to feed your own page. Deduplicate per request with Reactcache(); cache across requests with rule 3. -
Cache with
"use cache"+cacheLife+cacheTag; everything else is dynamic. With Cache Components, nothing is cached unless you say so, andfetchhas no implicit cache.import { cacheLife, cacheTag } from "next/cache"; export async function getInvoice(id: InvoiceId) { "use cache"; cacheLife("minutes"); cacheTag(`invoice:${id}`); return db.invoice.findUnique({ where: { id } }); }Don't use the old segment config (
export const dynamic,revalidate,fetchCache): it errors under Cache Components. Arguments and closed-over values become part of the cache key, so pass ids, not whole request objects. Details, the profiles and"use cache: private": references/caching.md. -
Invalidate with
updateTagin Server Actions,revalidateTag(tag, "max")elsewhere.updateTag(tag)gives read-your-own-writes and only works inside a Server Action. From a webhook or route handler userevalidateTag(tag, "max")(stale-while-revalidate) orrevalidateTag(tag, { expire: 0 }). The one-argumentrevalidateTag(tag)is deprecated. -
Wrap request-time data in
<Suspense>, or the build fails.cookies(),headers(),searchParams,connection()and uncached I/O outside a Suspense boundary stop prerendering with "uncached or runtime data during prerendering". Give it a boundary with a skeleton fallback, cache it, or (root layout only, see rule 14) mark the route blocking withexport const instant = false. All ofparams,searchParams,cookies(),headers()are async:awaitthem. -
Every Server Action validates its input and checks authorisation itself. A Server Action is a public POST endpoint whatever page it's written on. Parse
FormDatawith the schema fromtypescript-production(valibot), then authorise, then mutate, thenupdateTag. Return a typed result for expected failures; throw only for bugs. The full rule set is innextjs-security."use server"; export async function renameProject(_: State, form: FormData): Promise<State> { const session = await requireSession(); // 401 if absent const input = v.safeParse(RenameProject, Object.fromEntries(form)); if (!input.success) return { ok: false, errors: flatten(input.issues) }; await assertCanEdit(session, input.output.projectId); // 403 if not allowed await db.project.update(…); updateTag(`project:${input.output.projectId}`); return { ok: true }; } -
Route handlers are for other clients (webhooks, mobile, health), not for your own pages. Put them under
app/api/. Validate the body like an action, returnResponse.json(...), and set status codes on purpose. -
Every app has
GET /api/health, dynamic and dependency-free. DockerHEALTHCHECKand the Kubernetes probes hit it. It must answer without touching the database, so a slow dependency doesn't restart every pod.await connection()keeps it from being prerendered into a static file. It's in the template atsrc/app/api/health/route.ts;proxy.tsexcludes/api/healthfrom its matcher. -
error.tsx,loading.tsxandnot-found.tsxat the root, and per segment where the UX differs.error.tsxis a client component; log throughinstrumentation.ts'sonRequestError, show a retry withreset, never the error message. CallnotFound()for missing records instead of rendering an empty state. -
NEXT_PUBLIC_*is baked in at build; everything else is read at runtime on the server. One image is promoted from main to production unchanged, so aNEXT_PUBLIC_value is the same in every environment. Only put build-time constants there (app name, a public Sentry DSN that is the same everywhere). Per-environment config (API URLs, feature flags, secrets) is a server-sideprocess.env.X, read in a Server Component or action, and passed to the client as props if needed. Validate env once insrc/env.ts(valibot) and import from there. Never use theenvkey innext.config.ts: it's inlined into client bundles. -
Mark server-only modules with
import "server-only". Anything that reads secrets, talks to the DB or imports an SDK with credentials. A client import of it then fails the build instead of leaking. -
after()for work that shouldn't delay the response (audit logs, analytics, cache warm-up). Readcookies()/headers()before callingafter, not inside it. It runs in the same process, so the pod needs its SIGTERM grace period (the chart setsterminationGracePeriodSeconds). -
Instrument with
src/instrumentation.ts:registerOTel+onRequestError. OpenTelemetry via@vercel/otel, exporter configured by the standardOTEL_EXPORTER_OTLP_*env vars at runtime. See references/observability.md. -
Keep
output: "standalone"and never import from outside the workspace. The Docker runner copies only.next/standalone,.next/staticandpublic. In the monorepo,server.jslands at.next/standalone/apps/<app>/server.js; the workspace root is found frompnpm-lock.yaml, so don't setoutputFileTracingRootorturbopack.rootunless the build warns. Self-hosting details (multi-replica, Server Action keys,deploymentId): references/self-hosting.md. -
The root layout reads the CSP nonce, so the app renders per request. The template's
proxy.tssets a nonce-based CSP andsrc/app/layout.tsxhasexport const instant = false. That's a deliberate trade: nonces and prerendered static shells can't coexist, and for logged-in business apps a strict CSP wins. Data caching with"use cache"still works. Don't remove either half without readingnextjs-securityrule 4. -
Styles: global BEM SCSS, imported by the component. Turbopack compiles Sass with the
sasspackage;sassOptions.loadPaths: ["src/styles"]makes@use "abstracts"work from any depth. No~imports, nosassOptions.functions. Rules inscss-bem. -
Images through
next/image, fonts throughnext/font.sharpcomes with Next for the optimiser; the runner image needs no extra package. Remote images needimages.remotePatternswith an exact hostname. -
Check bundle size with
pnpm next analyze(Turbopack's analyzer;@next/bundle-analyzeris webpack-only). Lazy-load heavy client widgets (nuvui editor, maps, charts below the fold) withnext/dynamic. -
Use typed routes and generated types.
typedRoutes: truechecks<Link href>;PageProps<"/invoices/[id]">andLayoutPropsare global afternext typegen(run automatically bynext dev/next build). Don't hand-writeparamstypes. -
No
next lint. It was removed in 16. Biome lints (seebiome); TypeScript checks withtsc --noEmitper package.
Verify
pnpm typecheck
pnpm lint
pnpm build # turbo run build: fails on prerender errors, type errors, Sass errors
pnpm --filter <app> start & # then:
curl -fsS localhost:3000/api/health
Sources
- https://nextjs.org/docs/app/guides/upgrading/version-16
- https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents
- https://nextjs.org/docs/app/guides/migrating-to-cache-components
- https://nextjs.org/docs/app/api-reference/directives/use-cache
- https://nextjs.org/docs/app/api-reference/functions/revalidateTag
- https://nextjs.org/docs/app/api-reference/functions/updateTag
- https://nextjs.org/docs/messages/blocking-prerender-dynamic
- https://nextjs.org/docs/app/api-reference/functions/after
- https://nextjs.org/docs/app/guides/open-telemetry
- https://nextjs.org/docs/app/api-reference/config/next-config-js/output
- https://nextjs.org/docs/app/guides/self-hosting
- https://nextjs.org/docs/app/guides/environment-variables
- https://nextjs.org/docs/app/guides/sass
- https://nextjs.org/docs/app/guides/package-bundling
- https://nextjs.org/docs/app/api-reference/file-conventions/proxy