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:

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' },
        ],
      },
    ];
  },
};
How a source pattern maps headers onto matching routes A source pattern in next.config.js tests each requested route and applies its headers array only to routes that match. source: '/api/(.*)' match? /api/users /api/orders /dashboard /_next/static/x.js yes no headers array applied to matched routes
A source pattern tests each requested route; the headers array is applied only to routes that match.

Conditional matching with has and missing

Beyond source, each entry accepts optional has and missing arrays that gate the rule on request attributes — header, cookie, query, or host. This is the only request-aware lever the config layer exposes, and it still cannot read a value into the output; it only decides whether the static entry fires. Use it to skip headers already injected by an upstream proxy or CDN, or to apply a stricter policy to a specific host in a multi-domain deployment.

// next.config.js — apply the header only when an upstream marker is absent.
{
  source: '/(.*)',
  missing: [{ type: 'header', key: 'x-skip-security-headers' }],
  headers: [
    { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
  ],
},

Two further prefix rules trip people up. When basePath is set, every source is automatically prefixed with it; pass basePath: false on the entry to opt out. Likewise, with i18n routing enabled, source patterns are locale-prefixed unless you set locale: false. If a header silently fails to match on a sub-path or a localized route, an unexpected automatic prefix is the usual cause — inspect the resolved route with curl -sI before touching the pattern itself.

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" }
      ]
    }
  ]
}

The choice between the three mechanisms is not a matter of taste — each has a single condition under which it is the correct home for a header. A per-request value such as a CSP nonce can never come from build-time config, so it belongs in middleware. A static export has no server to run headers(), so on Vercel it belongs in vercel.json. Everything else — the common server-rendered or hybrid build — belongs in next.config.js headers(), which is the least infrastructure to reason about.

Choosing where to define a security header in a Next.js app Per-request values go in middleware, static exports go in vercel.json, and server-rendered builds use the next.config.js headers array. Header needs a per-request value? (e.g. CSP nonce) yes no Use middleware Static export? output: 'export' yes no vercel.json headers array next.config.js headers()
Per-request values need middleware; static exports use vercel.json; everything else uses the next.config.js headers array.

Diagnostic & Verification Steps

headers() is evaluated only by next build and is silently ignored under next dev. Always verify against a production build. The distinction below is the single most common reason a correct config appears to “do nothing”: the header pipeline runs in one mode and not the other.

Where headers() is evaluated: dev server versus production build next dev skips the headers() export while next build evaluates it and emits the security headers on every matching response. Two run modes, two outcomes next dev next.config.js headers() dev server export not evaluated Response no security headers next build, then next start next.config.js headers() build evaluates headers() baked in Response headers emitted Always verify against a production build, never the dev server.
next dev skips the headers() export; only next build bakes the headers into every matching response.
# 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'

Static build assets are the easy blind spot: a /(.*) entry matches them, but it is worth confirming explicitly because a broken pattern fails silently there too. Pick a real hashed file from .next/static and request it:

# Resolve one built asset, then inspect its headers.
ASSET=$(find .next/static -name '*.js' | head -n1 | sed 's#.next##')
curl -sI "http://localhost:3000/_next/static${ASSET#/static}" | grep -iE 'strict-transport|cache-control'

Expected output on a correctly configured build — the immutable cache policy from the scoped entry plus the site-wide HSTS line:

strict-transport-security: max-age=63072000; includeSubDomains; preload
cache-control: public, max-age=31536000, immutable

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

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.