React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummarynextjs-patterns

Next.js Patterns Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. 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.
  2. Guard Secrets With server-only: Put import "server-only" at the top of any module that touches AUTH_SECRET, database URLs, or signing keys so a Client Component importing it fails the build instead of leaking the value into the browser bundle.
  3. Harden Session Cookies: Set httpOnly: true to block JS access, secure: true in production to require HTTPS, and sameSite: "lax" for baseline CSRF protection; sameSite: "none" additionally requires secure: true or the browser drops the cookie entirely.
  4. Reach for NextRequest, Not Request: Import NextRequest/NextResponse from next/server so you get .nextUrl, .cookies, .geo, and static helpers like NextResponse.json() without rebuilding them on top of the Web Request.
  5. Await Dynamic params in Route Handlers: In Next.js 15+ the second argument is { params: Promise<{ ... }> }; destructuring without await params yields a Promise and your lookup silently returns undefined.
  6. 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 Zod safeParse for fully typed input.
  7. 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.
  8. 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.
  9. 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.
  10. Avoid Dynamic process.env on the Client: Next.js does static string replacement, not runtime lookup, so process.env[varName] is always undefined in client code — only literal references like process.env.NEXT_PUBLIC_APP_URL are inlined.
  11. Validate Env With Zod at Startup: Parse process.env through a Zod schema in lib/env.ts so missing or malformed variables fail fast with a clear message before the first request, and you get a fully typed env object for free.
  12. error.tsx Must Be a Client Component: React Error Boundaries rely on class lifecycle, so every error.tsx needs "use client" at the top; without it the build errors and no boundary is installed for that segment.
  13. global-error.tsx Renders Its Own html/body: When the root layout itself fails, global-error.tsx replaces the entire document, so it must render <html><body>…</body></html>; it also only activates in production (dev shows the Next.js overlay).
  14. Return Discriminated Unions From Actions: Type Server Action results as { success: true; data: T } | { success: false; error: string } for expected validation failures, and reserve throw for unexpected errors that should trigger the nearest error.tsx.
  15. Call redirect Outside try/catch: redirect() (and notFound()) throw a NEXT_REDIRECT sentinel error, so a surrounding try/catch swallows the navigation — place the call after all recoverable logic or rethrow the sentinel.
  16. Use output: standalone and Copy Assets: output: "standalone" produces a minimal self-contained server, but .next/static and public/ are not included — copy them into the standalone directory (or front with a CDN/reverse proxy) or static assets 404.
  17. Set HOSTNAME 0.0.0.0 in Docker: The Next.js server binds to 127.0.0.1 by default, which is unreachable from outside a container; set ENV HOSTNAME="0.0.0.0" (and ENV PORT=3000) in the Dockerfile so the port mapping works.
  18. Know the Edge Runtime's Limits: runtime = "edge" runs in a V8 isolate with no Node built-ins — no fs, path, child_process, or Buffer, and you must use globalThis.crypto; fall back to "nodejs" whenever you need those APIs.
  19. Set metadataBase in the Root Layout: All relative URLs in openGraph, twitter, and alternates resolve against metadataBase; without metadataBase: new URL("https://myapp.com") your OG images and canonicals ship as broken relative paths.
  20. 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 the ResolvingMetadata argument instead of duplicating fields.
  21. Use sitemap.ts and robots.ts Conventions: Exporting a default function from app/sitemap.ts auto-serves /sitemap.xml and app/robots.ts auto-serves /robots.txt; split into multiple sitemap Route Handlers once a site exceeds the 50,000-URL sitemap limit.
  22. 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.
  23. Return Every Locale From generateStaticParams: Pre-render all locales at build time by returning each one from generateStaticParams; missing locales silently 404 in production unless dynamicParams is enabled.
  24. Mock next/headers and next/cache in Tests: cookies(), headers(), revalidatePath, and revalidateTag throw outside the Next.js request context, so stub them with vi.mock("next/headers", …) / vi.mock("next/cache", …) before importing the module under test.
  25. 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 of render(<PostList />); also mock with vi.mock() before dynamic import() to ensure the mock wins.