Next.js Setup Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Generate a Real AUTH_SECRET: Auth.js requires a 32+ character
AUTH_SECRET; usenpx auth secretso you get a cryptographically strong value, and give dev and prod different secrets so a leaked dev key does not compromise real sessions. - Split auth.config From auth.ts: Keep an edge-safe
auth.config.ts(no adapters, no DB drivers) separate fromauth.tswhere you wire in the database adapter, so middleware can callauth()under the Edge Runtime without pulling in Node-only code. - Augment the Session Type: Declare custom claims on
SessionandJWTintypes/next-auth.d.tsvia module augmentation sosession.user.role,subscriptionStatus, etc. are typed in every Server Component, action, and middleware. - Pin create-next-app for Reproducibility: CLI defaults drift between versions and the interactive mode silently reuses cached preferences, so pin
create-next-app@15(or whichever major you want) and pass--reset-preferencesor explicit flags in CI so scaffolds are deterministic. - Cache fetch Explicitly in Next 15: The default in Next.js 15+ is
cache: "auto", notforce-cache, so setcacheornext: { revalidate: N }on every fetch instead of assuming data is cached — silent per-request fetching is a common regression from Next 14. - Copy static and public Into standalone:
output: "standalone"produces a tiny self-contained server but excludes.next/staticandpublic/— your Dockerfile or deploy script must copy both in, otherwise the app serves 404s for assets. - Set HOSTNAME=0.0.0.0 in Containers: The standalone server binds to
127.0.0.1by default, so inside a Fargate/EKS/Docker container you must setENV HOSTNAME=0.0.0.0(and an explicitPORT) or the load balancer's health check never reaches the app. - Use a Shared ISR Cache Handler: ISR data is cached per-container by default, so multi-container deployments show inconsistent versions until you configure a Redis-backed
cacheHandlerinnext.configwithcacheMaxMemorySize: 0. - Prefer pm2 reload Over restart: On EC2,
pm2 reloaddoes a graceful rolling restart across cluster workers for zero-downtime deploys;pm2 restartkills all workers and produces a visible outage window. - Let Nginx Serve _next/static: Configure Nginx to serve
/_next/static/andpublic/directly withCache-Control: public, immutable, max-age=31536000so static assets bypass Node entirely and survive app restarts. - Preserve .next/cache Between Deploys: Deploy scripts should delete
.next/server/and.next/static/but keep.next/cache/so ISR and build caches survive; a bluntrm -rf .nextwipes them and forces full rebuilds plus cold ISR regeneration. - Use a Singleton Prisma Client: Attach the client to
globalThisin development so hot-reload does not open a new connection pool every save — without this, Postgres refuses new connections within minutes and the dev server hangs. - Put DATABASE_URL in .env: The Prisma CLI reads
.env, not.env.local, soprisma generateandmigratefail silently or with confusing messages if the URL only lives in.env.local. - revalidatePath After Mutations: Server Actions that write data must call
revalidatePath(orrevalidateTag) afterward, otherwise the cached Server Component render keeps serving stale data and the UI appears to not update. - Use prisma migrate deploy in CI: CI pipelines should run
prisma migrate deploy(non-interactive, applies committed migrations);prisma migrate devis interactive, can reset the database, and must never run against production or staging. - Validate MDX Frontmatter With Zod:
gray-matterreturns whatever keys happen to be in the file, so run the result through a Zod schema to catch typos and missing required fields at import time instead of shipping broken docs. - Escape Braces and JSX in MDX Prose: Curly braces evaluate as JavaScript and
<Tag />parses as JSX, so wrap code samples in backticks or escape the braces — otherwise MDX throws build errors on prose that looks fine in plain Markdown. - Create Stripe Customers at Signup: Create the Stripe customer in Auth.js
events.createUser, not during checkout, so there is no double-click race that produces duplicate customers and the user has a Stripe ID the moment they exist. - Verify Stripe Webhooks With constructEvent: A Stripe webhook handler must read the raw body and call
stripe.webhooks.constructEvent(rawBody, sig, whsec); parsing JSON without verifying the signature lets anyone mutate your database via forged POSTs. - Force nodejs Runtime for Webhooks: Set
export const runtime = "nodejs"on the Stripe webhook route becausecrypto.createHmacis not available on the Edge Runtime, and use a distinctSTRIPE_WEBHOOK_SECRETper environment or signatures silently fail. - Tailwind v4 Config Lives in CSS: Tailwind v4 has no
tailwind.config.js— theme tokens, variants, and plugins are declared inglobals.cssvia@themeand@custom-variant, and the PostCSS plugin is@tailwindcss/postcss, not the oldtailwindcsspackage. - Always Compose Classes With cn(): shadcn components collapse conflicting utilities via
cn()(clsx+tailwind-merge), so concatenate classes throughcn(...)instead of template strings — otherwise consumer prop overrides randomly lose to defaults. - Declare env Vars in turbo.json: Turborepo strips any environment variable not listed in the task's
envarray, so unlisted vars areundefinedat build time; declare every build-time env var explicitly or cache keys will not reflect your configuration. - transpilePackages for Workspace UI: Next.js will not compile raw TSX from
node_modules, so when consuming internal monorepo packages like@repo/uiadd them totranspilePackagesinnext.config.tsor imports error at build. - Use workspace: With pnpm:* The
workspace:*protocol is only understood by pnpm, yarn, and bun — npm does not parse it; set"packageManager": "pnpm@…"in rootpackage.jsonand use--frozen-lockfilein CI to avoid drift.