vercel.json headers vs next.config.js headers(): Which to Use
This guide is part of the Vercel & Next.js Security Header Management reference. Next.js gives you two static mechanisms for emitting security headers — the next.config.js headers() async function and the vercel.json headers array — and they overlap enough that teams frequently set the same key in both, shipping duplicate or conflicting Content-Security-Policy and HSTS lines. This page lays out the exact syntax of both, the portability and precedence trade-offs, how each behaves under static export, and which routes each one actually matches.
Configuration Syntax & Exact Values
Both mechanisms describe the same shape — a list of route patterns, each carrying a list of key/value header pairs — but they live in different files and are applied by different layers.
The next.config.js form is an async function export. It runs at next build and is baked into the Next.js routing layer:
// next.config.js — applied by the Next.js server at build time
module.exports = {
async headers() {
return [
{
// Next.js path syntax, NOT raw regex. '/(.*)' matches every route.
source: '/(.*)',
headers: [
// 2 years; includeSubDomains extends the policy to every subdomain.
{ 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' },
],
},
];
},
};
The vercel.json form is static JSON read by the Vercel edge router, independent of the framework:
{
"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" }
]
}
]
}
Annotated breakdown of the shared fields:
source— both use the samepath-to-regexp-style pattern syntax (/(.*),/:slug*,/api/:path*). Neither accepts a raw JavaScriptRegExpobject. The Vercel form additionally supportshas/missingrequest matchers (cookie, header, query, host) thatnext.config.jsdoes not in the same way.key— the literal header name. Casing is normalized to lowercase on the wire by both.value— a static string only in both forms. Neither can read the request or mint a per-request nonce; that always requires middleware.
Neither mechanism has an explicit always flag like Nginx’s add_header ... always or Apache’s Header always set. Both emit their headers on the responses they govern — including matched error pages — so the equivalent guarantee is implicit. There is no opt-in needed.
The single most important rule: do not set the same header key in both files on a server-rendered deployment. Both layers run, so the response carries the header twice — and for CSP, two policies are intersected by the browser in a way that almost always blocks more than intended.
Comparison: Portability, Precedence, Static Export, Route Matching
Portability. next.config.js headers() is part of the framework. It runs anywhere a Next.js server runs — a Node host, a Docker container, AWS, Netlify, or Vercel. vercel.json is read only by Vercel’s platform router; on any other host the file is inert and your headers silently vanish. If portability across hosts matters, keep your headers in next.config.js.
Precedence when both exist. On Vercel, both layers are evaluated and both contribute headers; there is no clean “winner.” Vercel’s edge applies its vercel.json headers around the Next.js response, so a key present in both appears twice in the raw response. This is not a last-writer-wins override — it is duplication. Browsers handle duplicate security headers inconsistently: duplicate Strict-Transport-Security lines are merged to the first valid one, but duplicate Content-Security-Policy lines are treated as two independent policies and enforced together (the most restrictive of each directive wins), which routinely breaks pages.
Static export behavior. With output: 'export', Next.js produces a pure static bundle with no server, so the headers() export is dropped at build — there is nothing to execute it. Those headers simply do not ship. On Vercel, vercel.json headers still apply to the exported static files because the platform router serves them. For a static-export site on Vercel, vercel.json is the only working option of the two.
Which routes each matches. headers() applies to routes Next.js owns — pages, route handlers, and any path you target with a source, including /_next/static/(.*) if you add it explicitly. vercel.json headers are applied by the platform router to every matched path it serves, which includes static files, public assets, and /_next/static without special handling. This is why CDN-level cache and security headers on truly static assets are often easier to guarantee from vercel.json.
Server-Side Configuration
next.config.js
Use this as the default for a server-rendered or hybrid app that you may one day move off Vercel. It is the portable choice.
// 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=()' },
],
},
{
// Cache hashed, immutable build assets aggressively.
source: '/_next/static/(.*)',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
},
};
export default nextConfig;
Full syntax detail for this mechanism is in How to set up the next.config.js headers() array.
vercel.json
Use this when the deployment is a static export (output: 'export') on Vercel, or when you need the platform router’s has/missing request matchers. Keep the keys here disjoint from anything in next.config.js.
{
"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" }
]
},
{
"source": "/_next/static/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
]
}
Note: middleware for dynamic values
Neither file can produce a per-request value. A nonce-based CSP — script-src 'nonce-...' 'strict-dynamic' where the nonce changes on every request — must be generated in middleware.ts, because that is the only layer that runs per request before the response is sent. See Setting CSP with per-request nonces in Next.js middleware. When middleware sets a CSP, remove any CSP key from both next.config.js and vercel.json to avoid a second, conflicting policy.
Diagnostic & Verification Steps
Both mechanisms are evaluated only by a production build, never under next dev. Verify against a deployed preview or a local next build && next start.
# Inspect the raw response headers on a deployed preview URL.
curl -sI https://your-app.vercel.app/
Expected output (casing normalized to lowercase; order may vary):
HTTP/2 200
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
The critical check is for duplication. If a key is set in both files, it appears twice:
# Count how many times a key appears. Expected: 1.
curl -sI https://your-app.vercel.app/ | grep -ic 'strict-transport-security'
A count of 2 means the same key is defined in both next.config.js and vercel.json. Remove it from one. To confirm static-asset coverage on a static export, curl an asset path directly:
curl -sI https://your-app.vercel.app/_next/static/chunks/main.js | grep -i 'cache-control'
If that returns nothing on a static export, the header was set only in next.config.js (which was dropped at build) — move it to vercel.json.
Edge Cases, Security Implications & Safe Rollback
- Duplicate / conflicting headers when both are set. Symptom → fix:
curl -sIshows a key twice, and for CSP the page breaks more than expected → the key is defined in both files. Delete it from one. As a rule, never split a single security policy across both mechanisms; for CSP this is mandatory because the browser enforces both policies simultaneously, intersecting their allowances. /_next/staticassets and static export. Symptom → fix: security or cache headers missing on/_next/static/...after switching tooutput: 'export'→headers()was dropped at build because there is no server. Move the affected keys tovercel.json, which the platform router applies to static files.- App Router vs Pages Router. Both files are router-agnostic — they are config-level, not request-level, so they behave identically on the App Router and the Pages Router. The only behavior that differs by router is per-request CSP nonce generation, which neither file can do regardless of router; that is a middleware concern. See the CSP nonce reference.
- Safe rollback. Both mechanisms are non-destructive, but an over-strict CSP can blank the page. To roll back: comment out or delete the offending entry, run
rm -rf .nextto purge the stale build cache, thennpm run build && npm start(or redeploy a preview) and confirm the baseline withcurl -sI. Re-introduce strict directives one at a time, re-checking after each.
Frequently Asked Questions
If I set a header in both vercel.json and next.config.js, which one wins?
Neither — both layers run and the header is emitted twice. There is no override. For most headers the browser merges duplicates, but duplicate Content-Security-Policy lines are enforced as two separate policies, which usually breaks the page. Define each key in exactly one file.
Which should I use for a Next.js static export?
vercel.json, if you deploy on Vercel. With output: 'export' there is no server, so the next.config.js headers() function is dropped at build and its headers never ship. The vercel.json headers array still applies because Vercel’s platform router serves the static files.
I might move off Vercel later. Which is portable?
next.config.js headers(). It is part of the framework and runs on any Next.js host. vercel.json is read only by Vercel’s router; on any other platform it is inert and your headers silently disappear.
Can either file set a per-request CSP nonce?
No. Both run at build time with static string values and no access to the request. A per-request nonce must come from middleware.ts, the only layer that runs on each request before the response is sent.
Conclusion
Default to next.config.js headers() for static, route-scoped security headers — it is portable across hosts and applies to every server-rendered route. Reach for vercel.json only when you ship a static export on Vercel or need the platform router’s request matchers, and use middleware for anything per-request. Whatever you pick, keep each header key in exactly one place. Roll out incrementally: stage with a short Strict-Transport-Security max-age, confirm with curl -sI on a preview deploy (watching the duplicate count), then promote to full production values.