Production — Cheat Sheet
Next.js · 9 topics. Download the PDF or the Instagram carousel and share it.
The Build, and What It Produces
The build is where static and dynamic are decided, where the bundle size is fixed, and where most production surprises are visible before they ship.
- ✓Prerendering runs your page code at build time, so a missing env var or unreachable database fails the build
- ✓Never silence TypeScript or ESLint in the build — run them as separate CI steps instead
- ✓Watch the route symbols, First Load JS, the shared chunk and the prerendered path count between deploys
- ✓Cache .next/cache in CI or every build recompiles from scratch
- ✓output: standalone produces a small self-contained server for Docker; output: export gives up most of the framework
npm run build
// 1. Compile TypeScript and JSX -> JavaScript (SWC)
// 2. Lint and typecheck (unless disabled)
// 3. Bundle client chunks, per route
// 4. Prerender run every static page and write HTML
// 5. Optimise minify, tree-shake, generate the manifests
// Phase 4 is the one that surprises people: your page code RUNS at
// build time. So a build fails if a static page:
// queries a database that is not reachable from CI
// reads an environment variable that is not set in CI
// calls an API that is down
// "Error occurred prerendering page /problems" means your code threw
// during the build, not that the route is misconfigured.
// Skipping checks in the build is a trap worth naming:
// next.config.js
typescript: { ignoreBuildErrors: true } // do not
eslint: { ignoreDuringBuilds: true } // do not
// Both hide the failure until runtime. Run tsc and eslint as separate
// CI steps if the build is slow, but do not silence them.Deploying — Vercel and Self-Hosting
Vercel runs Next as its authors intended and charges for it. Self-hosting is entirely viable and moves several framework features from automatic to your problem.
- ✓Vercel provides the CDN, function splitting, image optimisation and shared ISR cache that the framework assumes
- ✓Dynamic routes and image transforms are billed per use, so an accidental static-to-dynamic regression costs money
- ✓Self-hosting with output: standalone is viable but you own the CDN, the shared ISR cache, sharp and zero-downtime deploys
- ✓NEXT_PUBLIC_ values are baked in at build time, so one image cannot serve two environments
- ✓Deploy the backend first and additively, keep both in the same region, and point previews at staging
// git push -> build -> deploy. Nothing to configure for the common case.
// Every branch gets a preview URL; production is instant to roll back
// because previous builds stay served.
// What is handled for you:
// a global CDN for static assets and prerendered pages
// each route as a serverless or edge function, scaled to zero
// image optimisation on demand
// the ISR cache, shared across instances
// middleware at the edge
// The costs that surprise teams:
// function invocations and duration — a dynamic route is billed per
// request, so an accidental ○ -> ƒ regression shows up on the bill
// image transformations — billed per source image
// bandwidth
// A static-heavy content site is cheap here. A dynamic-heavy app with
// large traffic is where people start comparing.
// vercel.json for the things that are not automatic:
{
"regions": ["bom1"], // put functions near the DB
"crons": [{ "path": "/api/cron/digest", "schedule": "0 3 * * *" }]
}
// Region matters more than it sounds: functions in Washington talking
// to a database in Mumbai pay that round trip on every query.Node and Edge Runtimes
Edge starts instantly and runs close to the user, with a restricted API surface and no TCP. Node has everything and a cold start. Most routes should stay on Node.
- ✓The edge runtime is Web APIs only — no TCP, so no conventional database driver, and no native modules
- ✓Being near the user only helps if the work is self-contained; an edge route querying a distant database moves latency rather than removing it
- ✓Middleware always runs on edge, which is why it cannot read a session from a database
- ✓Node is the right default for pages and most handlers; edge suits redirects, token checks and proxied streams
- ✓Reduce cold starts with lazy imports and a reused client, and use a connection pooler because serverless opens many connections
export const runtime = 'nodejs' // the default export const runtime = 'edge' // EDGE // + near-zero cold start // + runs in a location near the user // + cheaper per invocation // - Web APIs only: no fs, no net, no most Node built-ins // - no TCP, so no Postgres/MySQL client — HTTP-based drivers only // - no native modules (bcrypt, sharp, canvas) // - a bundle size limit, typically a few MB // - shorter execution limits // NODE // + everything: any npm package, any database driver // + longer execution, larger bundles // - cold starts // - runs in one region // The trap: an edge function near the user that queries a database // in one region pays the distance on every query. Being close to the // user only helps if the work is self-contained — a redirect, a // header, a cached lookup, a token check. // Middleware is always edge, which is why it cannot read a session // from the database.
Performance — Bundles and Core Web Vitals
Server components already removed most client JavaScript. What is left is the boundary you drew, the libraries you import, and whether the LCP element is prioritised.
- ✓Analyse the bundle before optimising — the cause is usually one heavy import or a barrel file
- ✓Moving a component back to the server removes it from the bundle entirely, which beats code splitting
- ✓LCP is a rendering problem, INP is a JavaScript problem and CLS is a layout problem — different fixes
- ✓Making a route static removes the function invocation and cold start altogether
- ✓Server-side waterfalls are invisible in the network tab; parallelise independent fetches with Promise.all
npm i -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)
ANALYZE=true npm run build
// What it usually shows, in order of frequency:
// a date library (moment) in the shared chunk
// an icon set imported as a barrel
// a chart or editor library on a page most people never open
// two copies of a library from mismatched versions
// a client component pulling in a server-side dependency
// Split what is heavy and rarely used:
const Editor = dynamic(() => import('@/components/Editor'), {
ssr: false, // it touches window
loading: () => <EditorSkeleton />,
})
// Import per icon, never the barrel:
import { Search } from 'lucide-react' // one icon
import * as Icons from 'lucide-react' // the whole set
// And the Next-specific one: check whether the code needs to be on
// the client at all. Moving a component back to the server removes
// it from the bundle entirely, which beats any splitting.Monitoring and Debugging in Production
Errors happen on two sides of a boundary and in short-lived functions. Without a request id joining them, a production bug is a guess.
- ✓Production redacts server errors to a message and a digest — surface the digest so support can match a log line
- ✓instrumentation.ts plus onRequestError wires capture across server components, actions, handlers and edge
- ✓Client-side capture catches hydration mismatches and post-deploy chunk failures that server logs never see
- ✓Generate a request id in middleware, log it structurally, and forward it to your backend
- ✓Watch route-level error rates, p95 latency and the static/dynamic mix; reproduce caching bugs against a real build
// Server errors are redacted in production: the client sees a
// generic message and a digest, and the full stack is in your logs
// under that digest. Show the digest, or the user has nothing to
// quote to support.
export default function Error({ error }) {
return <p>Something went wrong. Reference: {error.digest}</p>
}
// Capture both sides. Sentry's Next SDK wires server components,
// actions, route handlers and the browser:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') await import('./sentry.server.config')
if (process.env.NEXT_RUNTIME === 'edge') await import('./sentry.edge.config')
}
export const onRequestError = Sentry.captureRequestError
// What client-side capture catches that server logs never will:
// hydration mismatches
// chunk load failures after a deploy
// errors in event handlers, which no boundary catches
// failures on browsers you do not test
// A global handler for unhandled rejections, so they are at least
// counted:
window.addEventListener('unhandledrejection', (e) => report(e.reason))Testing a Next Application
Async server components do not fit unit test runners well. The practical answer is fewer unit tests, more integration tests, and Playwright for anything that involves the server.
- ✓Async server components cannot be rendered by Testing Library — test the data functions directly instead
- ✓Run Playwright against a production build, since the dev server prerenders and caches differently
- ✓Only an end-to-end test can prove that revalidation actually invalidated the right pages
- ✓Server actions are plain async functions, so their authorisation checks can be unit tested directly
- ✓Test auth, redirects, revalidation, status codes and metadata — not that the framework routes correctly
// UNIT — plain functions. The easiest and highest-value tests, and
// the App Router pushes more logic here than the Pages Router did.
import { calculateReadiness } from '@/lib/scoring'
expect(calculateReadiness(attempts)).toEqual({ score: 26 })
// Data functions with the database mocked, or against a test DB:
vi.mock('@/lib/db')
expect(await getProblemsForUser()).toHaveLength(3)
// COMPONENT (Vitest + Testing Library) — client components only
import { render, screen } from '@testing-library/react'
render(<ProblemFilters onChange={fn} />) // 'use client'
// Async SERVER components do not render in Testing Library. You can
// await the function and inspect the returned element, but you are
// then testing an object, not behaviour:
const el = await ProblemsPage({ params: Promise.resolve({ slug: 'x' }) })
// Possible; rarely worth it.
// E2E (Playwright) — anything involving the server: server
// components, server actions, middleware, auth, redirects, caching.
// This is where the App Router's real behaviour is testable at all.next.config and Build Configuration
One file controls redirects, headers, image hosts, rewrites and the experimental flags. Most of what belongs here is infrastructure, and most of what does not is business logic.
- ✓Image hosts, redirects, security headers and rewrites are what nearly every project configures here
- ✓A redirect changes the URL; a rewrite keeps it and serves different content
- ✓ignoreBuildErrors and ignoreDuringBuilds convert build failures into runtime failures — never ship them
- ✓Every experimental flag changes behaviour, so record why it is enabled
- ✓The config is evaluated at build time, so values read from the environment are frozen into the build
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**.supabase.co', pathname: '/storage/**' },
],
formats: ['image/avif', 'image/webp'],
},
async redirects() {
return [
// moved 2026-08 — keep until the old links stop appearing in logs
{ source: '/old-problems', destination: '/dsa/problems', permanent: true },
]
},
async headers() {
return [{ source: '/:path*', headers: SECURITY_HEADERS }]
},
async rewrites() {
// proxy the API so cookies stay first-party
return [{ source: '/api/:path*', destination: `${process.env.API_URL}/:path*` }]
},
// Trim large barrel-file libraries
experimental: { optimizePackageImports: ['lucide-react', 'date-fns'] },
}
// redirect — the URL changes, the browser sees the new one (301/308)
// rewrite — the URL stays, different content is served (a proxy)Caching at the Edge in Production
Beyond the framework's caches sits the CDN, and beyond that the browser. A cache key that ignores the user is the most damaging bug in this whole track.
- ✓A CDN caches by URL, so a cacheable personalised page can be served to the wrong user
- ✓Per-user routes must be no-store; use private with Vary if a response must be cached at all
- ✓Reading cookies() makes a route dynamic automatically, which is a safety feature rather than a limitation
- ✓Browser caches cannot be purged — cache pages at the CDN and reserve long browser caching for hashed assets
- ✓Verify with response headers such as x-vercel-cache and age rather than by refreshing and hoping
// browser cache -> CDN -> Next's caches -> your data
// What Next emits by default:
// a static page s-maxage long, stale-while-revalidate
// an ISR page s-maxage = your revalidate, SWR
// a dynamic page no-store — never cached anywhere
// /_next/static/* immutable, one year (filenames are hashed)
// s-maxage applies to shared caches (the CDN); max-age applies to
// the browser. The distinction matters:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400, max-age=0
// -> the CDN caches for an hour and can serve stale for a day while
// refreshing; the browser always revalidates.
// That combination is usually what you want: purging the CDN then
// fixes a bad page for everyone, rather than waiting out browser
// caches you cannot reach.
// In a route handler you set it yourself:
return NextResponse.json(data, {
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' },
})
// A route handler with no header and no revalidate is uncached in
// Next 15 and cached in 14 — be explicit.Migrating from the Pages Router
The two routers run side by side, so migration is incremental and route by route. The hard part is not the syntax — it is that data fetching and layouts work on different principles.
- ✓pages/ and app/ coexist, so migration is route by route with app/ taking precedence on conflicts
- ✓getServerSideProps has no equivalent — the component awaits its own data instead
- ✓next/router must become next/navigation, and the API differs rather than just the path
- ✓Do not port useEffect-based fetching; it usually collapses into a few lines in a server component
- ✓The Next 15 upgrade makes params, searchParams, cookies and headers async, and flips the caching defaults
// pages/_app.js -> app/layout.tsx (root layout)
// pages/_document.js -> app/layout.tsx (html and body live there)
// pages/index.js -> app/page.tsx
// pages/problems/[slug].js -> app/problems/[slug]/page.tsx
// pages/api/x.js -> app/api/x/route.ts
// pages/404.js -> app/not-found.tsx
// pages/500.js -> app/error.tsx / global-error.tsx
// getServerSideProps -> just await in the component
export async function getServerSideProps({ params }) { // before
const problem = await getProblem(params.slug)
return { props: { problem } }
}
export default async function Page({ params }) { // after
const problem = await getProblem((await params).slug)
return <Article problem={problem} />
}
// getStaticProps + revalidate -> export const revalidate
// getStaticPaths -> generateStaticParams
// next/head -> the metadata export
// next/router -> next/navigation (different API!)
// router.query -> useParams / useSearchParams
// router.pathname-> usePathname
// router.events -> gone; derive from usePathname
// The import path is the trap: next/router silently does nothing
// useful in app/, and the error is not obvious.