How to Set Up the next.config.js headers() Array
This guide is part of the Vercel & Next.js Security Header Management reference. It covers the precise syntax of the next.config.js headers() async function — the build-time mechanism for emitting static security headers like HSTS and Content-Security-Policy — and the route-matching, App-vs-Pages, and static-asset edge cases that cause it to silently misfire.
Configuration Syntax & Exact Values
headers() is an async export that returns an array. Each element is an object with a source (a Next.js path pattern, not raw regex) and a headers array of { key, value } objects. The function runs at build time, so every value must be a static string — it cannot read the request and cannot mint a per-request nonce.
// next.config.js
module.exports = {
async headers() {
return [
{
// source: Next.js path-matching syntax. '/(.*)' matches every route.
source: '/(.*)',
headers: [
// 1 year. includeSubDomains extends the policy to every subdomain.
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
// nosniff: forces strict MIME checking, blocking content-sniffing attacks.
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// DENY: page may not be framed anywhere; legacy clickjacking control.
{ key: 'X-Frame-Options', value: 'DENY' },
// Send only the origin on cross-origin navigations.
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
];
},
};
Annotated breakdown of each field:
source— a path pattern in Next.js syntax./(.*)is a named-free wildcard matching all paths. Named parameters (/:path*) and the negative form are supported; raw JavaScriptRegExpobjects are not.key— the exact header name. Casing is normalized to lowercase on the wire; write it conventionally for readability.value— a static string only. There is noalwaysflag as in Nginx or Apache, because Next.js emits these headers on every response it generates for matching routes, error pages included — the equivalent guarantee is built in.
To scope headers to a subset of routes, add more objects with narrower source patterns. More-specific entries do not override broader ones; all matching entries apply, so overlapping source patterns that set the same key produce duplicates.
// next.config.js — route-scoped headers
module.exports = {
async headers() {
return [
{
source: '/api/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: "default-src 'none'; frame-ancestors 'none'" },
{ key: 'Cache-Control', value: 'no-store' },
],
},
{
// Long-lived caching for hashed, immutable build assets.
source: '/_next/static/(.*)',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
},
};
Server-Side Configuration
next.config.js
Place the headers() export in next.config.js (or next.config.mjs with export default). It applies to server-rendered and statically-generated routes alike, on both the App Router and the Pages Router.
// next.config.mjs (ES module form)
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};
export default nextConfig;
Note: vercel.json alternative
If the deployment is a static export (output: 'export'), headers() is dropped at build — there is no server to run it. On Vercel, move these same keys into the vercel.json headers array, which the platform router applies to every path including static files. Do not define a key in both next.config.js and vercel.json on a server-rendered build, or the response carries duplicates. The full trade-off is in the vercel.json vs next.config.js comparison.
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}
Diagnostic & Verification Steps
headers() is evaluated only by next build and is silently ignored under next dev. Always verify against a production build.
# 1. Build and start the production server.
npm run build && npm start
# 2. Inspect the raw response headers on the root route.
curl -sI http://localhost:3000/
Expected output (casing normalized to lowercase by Next.js; order may vary):
HTTP/1.1 200 OK
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=()
Verify scoped entries on the paths they target, and confirm a security key appears exactly once:
# Route-scoped headers on an API path.
curl -sI http://localhost:3000/api/test | grep -iE 'content-security|cache-control'
# Confirm no duplicate HSTS line (expected: 1).
curl -sI http://localhost:3000/ | grep -ic 'strict-transport-security'
If a header is absent, the build did not pick up the config: confirm you ran next build (not next dev), and that output: 'export' is not set.
Edge Cases, Security Implications & Safe Rollback
sourcepath matching is not raw regex. Symptom → fix: a header fails to apply to a route you expected → you used a JavaScriptRegExppattern. Rewrite it in Next.js path syntax (/(.*),/:slug*); raw regex is silently non-matching.- Headers not applied to
/_next/staticassets.headers()only applies to routes matched by asourcepattern. A singlesource: '/(.*)'matches application routes but you must verify static asset paths explicitly. Symptom → fix:curl -sIon a/_next/static/...URL shows no security headers → add a dedicatedsource: '/_next/static/(.*)'entry, or set the headers invercel.json, which applies platform-wide. - App Router vs Pages Router.
headers()behaves identically on both routers — it is config-level, not request-level. The difference appears only with CSP nonces, whichnext.config.jscannot produce on either router; per-request nonces require middleware regardless of which router the app uses. - Safe rollback. Headers are non-destructive, but an over-strict CSP can blank the page. To roll back: comment out the offending entry in
headers(), runrm -rf .nextto purge the stale build cache, thennpm run build && npm startto confirm the baseline response. Re-introduce strict directives one at a time, validating withcurl -sIafter each.
Frequently Asked Questions
Why are my headers ignored when I run the dev server?
next dev does not evaluate the headers() export. Only next build does. Verify with npm run build && npm start or on a deployed preview.
Can I put a CSP nonce in the headers() array?
No. headers() runs at build time with no access to the request, so every value is a static string. Per-request nonces must be set in middleware. See Setting CSP with nonces in Next.js middleware.
Why do two source patterns produce duplicate headers?
All matching source entries apply — more-specific patterns do not override broader ones. If two entries set the same key for a route, the response carries both. Scope your patterns so each key is set once per route.
Does headers() work the same on the App Router and Pages Router?
Yes. headers() is config-level and router-agnostic. The only relevant difference is that neither router can generate CSP nonces from config — that requires middleware.
Conclusion
Use the headers() array for static, route-scoped security headers that need no per-request context, and validate every change against a production build (next build && next start) since next dev ignores the export entirely. Roll out incrementally: stage with a short Strict-Transport-Security max-age, confirm the headers on a preview deploy with curl -sI, then promote to full production values. For anything per-request, move to middleware.