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:

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.

Decision matrix: vercel.json vs next.config.js vs middleware Three columns compare where to set headers based on portability, static export support, and per-request needs, pointing each requirement at the right mechanism. next.config.js vercel.json middleware Portable to any Next.js host Vercel-only edge router Per-request nonce / dynamic Dropped on static export Covers static export + assets Forces dynamic rendering Static + portable: next.config.js. Vercel static export/assets: vercel.json. Per-request CSP nonce: middleware. Never set the same key in two of them.
Pick by requirement: portability and static routes go to next.config.js, Vercel-specific static export coverage to vercel.json, per-request values to middleware.

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

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.