Next.js Patterns Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Combine Middleware and Layout Auth: Use edge middleware for a fast "is there a session cookie?" gate and do the real JWT/database verification in a Server Component or layout — middleware cannot query a database, so skipping the second step lets forged cookies through.
- Guard Secrets With server-only: Put
import "server-only"at the top of any module that touchesAUTH_SECRET, database URLs, or signing keys so a Client Component importing it fails the build instead of leaking the value into the browser bundle. - Harden Session Cookies: Set
httpOnly: trueto block JS access,secure: truein production to require HTTPS, andsameSite: "lax"for baseline CSRF protection;sameSite: "none"additionally requiressecure: trueor the browser drops the cookie entirely. - Reach for NextRequest, Not Request: Import
NextRequest/NextResponsefromnext/serverso you get.nextUrl,.cookies,.geo, and static helpers likeNextResponse.json()without rebuilding them on top of the WebRequest. - Await Dynamic params in Route Handlers: In Next.js 15+ the second argument is
{ params: Promise<{ ... }> }; destructuring withoutawait paramsyields a Promise and your lookup silently returnsundefined. - Wrap request.json in try/catch: An empty or malformed body makes
await request.json()throw, so wrap it and return{ error: "Invalid JSON" }with status 400 — or validate with a ZodsafeParsefor fully typed input. - Return a Bare 204: For no-content responses use
new NextResponse(null, { status: 204 });NextResponse.json(null, { status: 204 })sends the string"null"as a body and violates the 204 contract. - Use Atomic Updates for Counters: For credits and rate limits, use
updateMany({ where: { credits: { gt: 0 } }, data: { credits: { decrement: 1 } } })or "increment first, then check, rollback on overflow" to close the TOCTOU window between read and write. - Never Put Secrets Behind NEXT_PUBLIC_: Any variable with the
NEXT_PUBLIC_prefix is inlined into the client bundle at build time, so reserve it for publishable keys and public URLs — database URLs, API keys, and signing secrets must stay server-only. - Avoid Dynamic process.env on the Client: Next.js does static string replacement, not runtime lookup, so
process.env[varName]is alwaysundefinedin client code — only literal references likeprocess.env.NEXT_PUBLIC_APP_URLare inlined. - Validate Env With Zod at Startup: Parse
process.envthrough a Zod schema inlib/env.tsso missing or malformed variables fail fast with a clear message before the first request, and you get a fully typedenvobject for free. - error.tsx Must Be a Client Component: React Error Boundaries rely on class lifecycle, so every
error.tsxneeds"use client"at the top; without it the build errors and no boundary is installed for that segment. - global-error.tsx Renders Its Own html/body: When the root layout itself fails,
global-error.tsxreplaces the entire document, so it must render<html><body>…</body></html>; it also only activates in production (dev shows the Next.js overlay). - Return Discriminated Unions From Actions: Type Server Action results as
{ success: true; data: T } | { success: false; error: string }for expected validation failures, and reservethrowfor unexpected errors that should trigger the nearesterror.tsx. - Call redirect Outside try/catch:
redirect()(andnotFound()) throw aNEXT_REDIRECTsentinel error, so a surroundingtry/catchswallows the navigation — place the call after all recoverable logic or rethrow the sentinel. - Use output: standalone and Copy Assets:
output: "standalone"produces a minimal self-contained server, but.next/staticandpublic/are not included — copy them into the standalone directory (or front with a CDN/reverse proxy) or static assets 404. - Set HOSTNAME 0.0.0.0 in Docker: The Next.js server binds to
127.0.0.1by default, which is unreachable from outside a container; setENV HOSTNAME="0.0.0.0"(andENV PORT=3000) in the Dockerfile so the port mapping works. - Know the Edge Runtime's Limits:
runtime = "edge"runs in a V8 isolate with no Node built-ins — nofs,path,child_process, orBuffer, and you must useglobalThis.crypto; fall back to"nodejs"whenever you need those APIs. - Set metadataBase in the Root Layout: All relative URLs in
openGraph,twitter, andalternatesresolve againstmetadataBase; withoutmetadataBase: new URL("https://myapp.com")your OG images and canonicals ship as broken relative paths. - Use generateMetadata for Dynamic Pages: For per-post titles, descriptions, and OG images, export
async generateMetadata({ params })(params is a Promise in Next.js 15+) and extend the parent via theResolvingMetadataargument instead of duplicating fields. - Use sitemap.ts and robots.ts Conventions: Exporting a default function from
app/sitemap.tsauto-serves/sitemap.xmlandapp/robots.tsauto-serves/robots.txt; split into multiple sitemap Route Handlers once a site exceeds the 50,000-URL sitemap limit. - Exclude Assets From the i18n Matcher: The locale-detecting middleware must skip
_next,api, and files with extensions (e.g.,matcher: ["/((?!_next|api|favicon.ico).*)"]) or it redirects static assets into locale-prefixed paths and breaks the page. - Return Every Locale From generateStaticParams: Pre-render all locales at build time by returning each one from
generateStaticParams; missing locales silently 404 in production unlessdynamicParamsis enabled. - Mock next/headers and next/cache in Tests:
cookies(),headers(),revalidatePath, andrevalidateTagthrow outside the Next.js request context, so stub them withvi.mock("next/headers", …)/vi.mock("next/cache", …)before importing the module under test. - Await Async Server Components in Tests: Server Components are async functions that return JSX, so in Vitest do
const jsx = await PostList(); render(jsx)instead ofrender(<PostList />); also mock withvi.mock()before dynamicimport()to ensure the mock wins.