The complete next.config.ts surface, plus the NEXT_PUBLIC_ build-time baking trap (why the same Docker image cannot serve staging and production), runtime environment injection patterns, and the instrumentation.ts startup hook.
P-9 — Configuration: next.config.ts, Environment Variables, and Instrumentation
Who this is for: Engineers who copy-paste next.config.js from the docs, tweak a few things, and hope for the best. This module covers the full configuration surface of Next.js 15 — what each knob does, when you actually need it, and the patterns that separate a production-hardened config from a default one.
next.config.ts — TypeScript Is Now the Default
For most of Next.js's history, the configuration file was next.config.js — plain JavaScript with a CommonJS export. As of Next.js 15, next.config.ts is the default. This matters because you get autocomplete and type checking on every configuration option, which means you catch mistakes before running next build rather than discovering them at 2am.
ts// next.config.ts import type { NextConfig } from 'next' const config: NextConfig = { // your config here } export default config
If you have an existing .js config, migrating to .ts is typically a rename plus swapping module.exports for export default. The occasional gotcha is plugins that wrap your config with a higher-order function — check that the plugin you're using exports proper TypeScript types. Most major ones (@next/bundle-analyzer, next-intl, @sentry/nextjs) do.
Redirects: Permanent, Temporary, and Conditional
Redirects belong in next.config.ts when they are structural — old URLs that have permanently moved, API endpoints that have been renamed, or paths you want to consolidate. They run at the routing layer, before any page or layout renders, so they're fast and require no JavaScript execution.
tsasync redirects() { return [ { source: '/blog/:slug', destination: '/articles/:slug', permanent: true, // 308 — tells crawlers to update their index }, { source: '/old-pricing', destination: '/pricing', permanent: false, // 307 — use for A/B tests or temporary changes }, { source: '/admin/:path*', destination: '/login', permanent: false, has: [{ type: 'cookie', key: 'session', missing: true }], }, ] },
The has and missing matchers let you make redirects conditional on headers, cookies, or query parameters. The last example redirects unauthenticated users away from the admin area at the config level — before Middleware even runs. Be careful with this approach: it's fast but it's also static and can't handle JWTs or complex session validation. Use Middleware for anything that needs actual logic.
permanent: true sends a 308 (Moved Permanently for non-GET methods) or 301 depending on the context. permanent: false sends a 307. The difference matters for SEO — permanent redirects transfer link equity, temporary ones don't. If you are genuinely moving a URL forever, use permanent: true. If you might change your mind, use permanent: false.
Rewrites: Proxying Without Redirecting
Rewrites change what Next.js renders without changing the URL the user sees. This is useful for proxying requests to external services, hiding implementation details, or maintaining URL stability during a migration.
tsasync rewrites() { return { beforeFiles: [ // these run before filesystem routes — can override pages { source: '/cdn/:path*', destination: 'https://cdn.acme.com/:path*', }, ], afterFiles: [ // these run after filesystem routes — only match if no page exists { source: '/legacy/:path*', destination: 'https://old.acme.com/:path*', }, ], fallback: [ // run if nothing else matches — useful for incremental migrations { source: '/:path*', destination: 'https://old.acme.com/:path*', }, ], } },
beforeFiles rewrites are powerful and dangerous in equal measure. They run before Next.js even checks whether a page file exists, which means a rewrite can shadow a real page. Use them intentionally. fallback rewrites are the cleanest way to do an incremental migration: new pages live in Next.js, unknown paths fall through to the old system.
Security Headers
The headers() function is where you add HTTP security headers to every response. This is one of those things that separates "shipped" from "production-hardened":
tsasync headers() { return [ { source: '/(.*)', headers: [ { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload', }, { key: 'Content-Security-Policy', value: [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // tighten this in production "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self' https://api.acme.com", ].join('; '), }, ], }, ] },
CSP (Content-Security-Policy) is the most powerful but also the most likely to break things. The unsafe-inline and unsafe-eval entries are pragmatic defaults for Next.js apps that use inline styles and the React hydration script — tighten them gradually as you audit your app. X-Frame-Options: SAMEORIGIN prevents clickjacking. HSTS with preload tells browsers to only connect over HTTPS and can be submitted to the HSTS preload list.
Run your deployed app through securityheaders.com after deploying. It grades your header configuration and tells you what's missing.
Image Configuration
The images config is where you whitelist external image domains and tune format support:
tsimages: { remotePatterns: [ { protocol: 'https', hostname: 'images.acme.com', pathname: '/uploads/**', }, { protocol: 'https', hostname: '**.cloudinary.com', }, ], formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], },
remotePatterns replaces the old domains array (which is now deprecated) and is significantly more secure — you can restrict not just to a hostname but to specific path patterns. The ** glob matches any subdomain.
formats: ['image/avif', 'image/webp'] enables both formats. AVIF is roughly 20% smaller than WebP for the same visual quality but takes longer to encode, which is why it's disabled by default. For a production app with an image CDN doing the optimization, enable both.
transpilePackages, serverExternalPackages, and Monorepo Config
transpilePackages tells Next.js to run specific npm packages through its Babel/SWC transform. You need this for two scenarios: packages in a monorepo that contain TypeScript or JSX and aren't pre-compiled, and packages distributed as ESM that Webpack can't handle natively:
tstranspilePackages: ['@acme/ui', '@acme/utils', 'some-esm-only-package'],
serverExternalPackages is the opposite: it tells Next.js to exclude certain packages from the server bundle and use native Node.js require() instead. You need this for packages with native bindings that can't be bundled: sharp, @prisma/client, canvas, bcrypt:
tsserverExternalPackages: ['sharp', '@prisma/client'],
Getting this wrong produces cryptic errors. If you see Module not found: Can't resolve 'fs' or similar Node.js module errors during the build, a package that needs native Node.js resolution has ended up in the bundle.
React Compiler and Typed Routes
Two newer config flags worth knowing about. reactCompiler: true enables the React Compiler (previously known as React Forget), which automatically memoizes your components and hooks without manual useMemo and useCallback calls. As of Next.js 15, it's stable enough for production:
tsexperimental: { reactCompiler: true, }
The compiler's job is to analyze your code and insert memoization where it's safe. The result is fewer unnecessary re-renders without the cognitive overhead of manually auditing which values need to be memoized. If you're on React 19 (which Next.js 15 requires), the compiler is worth enabling.
typedRoutes: true gives you type-safe href props on <Link> and in router.push(). Routes are statically analyzed and a TypeScript union type is generated from your file structure:
tsexperimental: { typedRoutes: true, }
With this enabled, <Link href="/daschboard"> (note the typo) becomes a TypeScript error. The generated types live in .next/types/link.d.ts. This is one of those features that feels like a minor convenience until it catches a broken link in code review.
Turbopack Configuration
Turbopack became stable in Next.js 15 for development. Enable it with --turbopack in your dev script or configure it in next.config.ts:
ts// next.config.ts const config: NextConfig = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js', }, }, resolveAlias: { '@': './src', }, }, }
The rules config maps file extensions to loaders — the Turbopack equivalent of Webpack's module.rules. The syntax is different from Webpack but the concept is the same. Not all Webpack loaders are Turbopack-compatible; check the Turbopack docs for supported loaders.
Environment Variables Done Right
Next.js has a hierarchy of environment files: .env (committed, defaults), .env.local (gitignored, local overrides), .env.development and .env.production (environment-specific, committed), and .env.development.local / .env.production.local (gitignored, local environment-specific overrides). The loading order means local files always win.
The NEXT_PUBLIC_ prefix is the single most important convention: variables prefixed with it are inlined into the client bundle. Everything else stays server-side. This means NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is fine to expose to the browser; STRIPE_SECRET_KEY (no prefix) stays on the server. If you ever see a secret key in a NEXT_PUBLIC_ variable, that's a security incident.
Validate your environment variables at startup with Zod rather than letting the app fail at runtime when it tries to use a missing value:
ts// src/env.ts import { z } from 'zod' const envSchema = z.object({ DATABASE_URL: z.string().url(), STRIPE_SECRET_KEY: z.string().startsWith('sk_'), NEXT_PUBLIC_SITE_URL: z.string().url(), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'), }) export const env = envSchema.parse(process.env)
Import env from this file instead of reading process.env directly. The Zod parse throws at module load time if anything is missing or malformed — you find out immediately when the server starts, not when the first request hits the code path that uses the missing value. This has saved production deployments.
instrumentation.ts — Server-Side Startup Hook
instrumentation.ts at the root of your project exports a register() function that Next.js calls exactly once when the server starts. This is the correct place to initialize OpenTelemetry, set up Sentry, or wire up any APM tooling:
ts// instrumentation.ts export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { // Only runs in Node.js runtime, not Edge const { NodeSDK } = await import('@opentelemetry/sdk-node') const { getNodeAutoInstrumentations } = await import('@opentelemetry/auto-instrumentations-node') const sdk = new NodeSDK({ traceExporter: /* your exporter */, instrumentations: [getNodeAutoInstrumentations()], }) sdk.start() } if (process.env.NEXT_RUNTIME === 'edge') { // Edge-compatible initialization } }
The NEXT_RUNTIME check is important. Next.js runs some routes in the Edge runtime and others in Node.js. OpenTelemetry's Node.js SDK doesn't work in the Edge runtime. Check the runtime before importing runtime-specific packages.
instrumentation.ts is not Middleware. It does not run on every request. It runs once, at startup. The confusion between the two is common; the mental model to use is "instrumentation is like an application constructor, middleware is like a request interceptor."
instrumentation-client.ts — Browser Startup Hook
instrumentation-client.ts is the client-side mirror of instrumentation.ts. It runs once in the browser when the application first loads, before any page renders:
ts// instrumentation-client.ts export function register() { if (typeof window !== 'undefined') { // Initialize browser analytics import('@segment/analytics-next').then(({ AnalyticsBrowser }) => { const analytics = AnalyticsBrowser.load({ writeKey: process.env.NEXT_PUBLIC_SEGMENT_KEY! }) window.__analytics = analytics }) // Initialize browser error tracking import('@sentry/nextjs').then(({ init }) => { init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN }) }) } } export function onRouteChange() { // Called on every client-side navigation window.__analytics?.page() }
The onRouteChange export is specific to instrumentation-client.ts — it's called by Next.js on every client-side route change, making it the right place to fire analytics page views without hooking into usePathname in every layout.
The combination of instrumentation.ts for server-side observability and instrumentation-client.ts for browser-side observability gives you full-stack coverage without spreading initialization code across layouts, providers, and components. That separation is the whole point.
The NEXT_PUBLIC_ Build-Time Trap
This is the most consistently misunderstood environment variable behaviour in Next.js, and it causes real production incidents when teams adopt Docker-based deployments.
The problem in one sentence: NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time, not read from the environment at runtime.
Here's what that means in practice. You build your Docker image:
bashNEXT_PUBLIC_API_URL=https://staging.api.example.com npm run build docker build -t my-app:v1.2.3 .
You push that image to your registry. Your CD pipeline promotes it from staging to production. Production containers start. Every API call your application makes goes to https://staging.api.example.com — because that value was baked into the bundle during the build. Setting NEXT_PUBLIC_API_URL=https://api.example.com in your production environment has no effect. The bundle doesn't read it at runtime.
This is not a bug. It's the design. NEXT_PUBLIC_ variables are statically replaced at compile time — Next.js runs a transform similar to sed -i "s/process.env.NEXT_PUBLIC_API_URL/'https:\/\/staging.api.example.com'/g" across your entire JavaScript output. The string is in the bundle. There's no process.env call at runtime.
Verifying the Problem
bash# Build with staging values NEXT_PUBLIC_API_URL=https://staging.api.example.com npm run build # Check the bundle — you'll find the literal string grep -r "staging.api.example.com" .next/static/chunks/ # Found: 4 matches in .next/static/chunks/app/page-abc123.js
Patterns for Runtime-Variable Configuration
If you need different values in different environments from the same Docker image, you have three options:
Option 1: Build per environment (simplest, most common)
Build a separate image per environment. Staging gets a staging build; production gets a production build. This is the easiest approach and works fine for most teams.
yaml# .github/workflows/deploy.yml - name: Build staging image run: | NEXT_PUBLIC_API_URL=${{ vars.STAGING_API_URL }} \ npm run build docker build -t my-app:staging-${{ github.sha }} . - name: Build production image run: | NEXT_PUBLIC_API_URL=${{ vars.PROD_API_URL }} \ npm run build docker build -t my-app:prod-${{ github.sha }} .
The downside: you're shipping untested code to production (the staging build is what was tested; the production build is a new artifact). For most teams this is an acceptable trade-off. For highly regulated systems, it isn't.
Option 2: Route handler as a config endpoint (same image, runtime config)
Instead of NEXT_PUBLIC_API_URL in the bundle, expose a /api/config Route Handler that reads server-side environment variables and returns them. The client fetches this once at startup.
ts// app/api/config/route.ts export async function GET() { return Response.json({ apiUrl: process.env.API_URL, // server-side, not NEXT_PUBLIC_ analyticsKey: process.env.ANALYTICS_KEY, }) }
ts// lib/config.ts — client-side config provider let config: AppConfig | null = null export async function getConfig(): Promise<AppConfig> { if (config) return config const res = await fetch('/api/config') config = await res.json() return config }
This works but adds a network round-trip on first use. Cache the result.
Option 3: publicRuntimeConfig (legacy, but still works)
ts// next.config.ts const config: NextConfig = { publicRuntimeConfig: { apiUrl: process.env.API_URL, }, } // Usage in components import getConfig from 'next/config' const { publicRuntimeConfig } = getConfig() const apiUrl = publicRuntimeConfig.apiUrl
publicRuntimeConfig is read at request time from the server's environment. It works for same-image promotions. The downside: it disables static rendering for all pages that use it — every page that calls getConfig() becomes server-rendered. Use it carefully.
Which Pattern to Use
| Deployment model | Recommended pattern |
|---|---|
| Vercel (separate preview/prod) | NEXT_PUBLIC_ — Vercel builds per deployment |
| Docker, one image per environment | NEXT_PUBLIC_ with separate builds |
| Docker, same image across environments | Route Handler config endpoint |
| Strict "build once, deploy anywhere" | Route Handler config endpoint |
Next.js Pages Router with getServerSideProps | publicRuntimeConfig |
The rule: if your deployment pipeline promotes the same Docker image from staging to production, NEXT_PUBLIC_ variables are the wrong tool. Use a config endpoint instead.
The CI Check
Catch accidental NEXT_PUBLIC_ usage in shared libraries that get bundled into both server and client:
yaml# .github/workflows/lint.yml - name: Check for NEXT_PUBLIC_ in server-only code run: | grep -r "NEXT_PUBLIC_" src/lib/server/ src/actions/ && \ echo "NEXT_PUBLIC_ found in server-only code — use process.env directly" && \ exit 1 || echo "OK"
Server-side code doesn't need NEXT_PUBLIC_ — server environment variables are available via process.env directly, without the build-time baking behaviour.