Next.js Content Security Policy: Nonces, Middleware, and Strict CSP (2026)

Learn how to add a strict Content Security Policy to your Next.js App Router app: per-request nonces in middleware, strict-dynamic, style-src pitfalls, and report-only rollout.

Next.js CSP Guide: Nonces & Middleware (2026)

Updated: July 3, 2026

To add a strict Content Security Policy (CSP) to a Next.js App Router app, generate a per-request nonce in middleware.ts (or proxy.ts on Next.js 16), attach it to the Content-Security-Policy response header, forward the same nonce through a request header so Server Components can read it via headers(), and pass it to every <Script>, inline <style>, and third-party embed. Anything you skip becomes a blocked resource and a broken page. This guide walks through the full setup, including strict-dynamic, report-only rollout, and the traps App Router introduces.

  • Next.js has no built-in CSP, so you set the header yourself in middleware (or proxy.ts in Next.js 16) so the nonce can be regenerated per request.
  • A strict policy uses 'nonce-{random}' 'strict-dynamic' for script-src; that combination lets loaded scripts load further scripts without you allowlisting every CDN.
  • Server Components read the nonce from the request header via headers(), then pass it to <Script nonce={nonce}>. Do not use the middleware nonce inside a client component directly.
  • Inline styles from libraries like Tailwind, Framer Motion, and Radix require 'unsafe-inline' in style-src, or a hash-based allowlist. The browser will not accept nonces on injected <style> tags added at runtime.
  • Roll out with Content-Security-Policy-Report-Only first, wire report-to to a collector, then flip to enforcement once violations quiet down.
  • Static routes (fully cached HTML) cannot carry a per-request nonce; either force the route to dynamic or serve 'unsafe-inline' for those routes only.

Why Next.js needs manual CSP setup

The official Content Security Policy guide is short for a reason: Next.js ships no built-in CSP. That's deliberate. A strict policy needs a fresh nonce on every HTML response, and Next.js aggressively caches routes, so a static HTML file cannot carry a header the middleware layer generates per request unless you deliberately opt that route out of the cache. The framework hands you the primitives (middleware, headers, the nonce prop on <Script>) and expects you to wire them up.

Setting CSP in next.config.js under headers() only works for a static policy (one without nonces). That's fine for permissive setups (e.g. script-src 'self' https://cdn.example.com) but useless for the pattern browser vendors and web.dev's strict-CSP guide now recommend: 'strict-dynamic' with a nonce. Honestly, that combination is the only way to defend against XSS without maintaining an ever-growing allowlist of third-party origins.

Two things changed recently that are worth knowing before you start. First, if you're on Next.js 16, middleware moved to proxy.ts at the project root (same API, new file name), and a codemod handles most call sites. I've written about the parts of the proxy.ts migration the codemod misses separately; the code in this article works in either file. Second, React 19 (bundled with Next.js 15+) is a lot stricter about dangerouslySetInnerHTML in ways that occasionally interact with CSP, and I'll flag those where they come up.

Generate a nonce in middleware

The nonce needs three properties: it must be cryptographically random, at least 128 bits, and different on every request. The Web Crypto API is available in Edge middleware, so you don't need to pull in Node's crypto module. Here's the minimum viable middleware.ts. I'll show what to add to it next.

// middleware.ts (or proxy.ts on Next.js 16)
import { NextResponse, type NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')

  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  // Forward the nonce to the app via a request header
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', cspHeader)

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  })
  response.headers.set('Content-Security-Policy', cspHeader)

  return response
}

export const config = {
  matcher: [
    // Skip static assets, images, and API routes that don't return HTML
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Two subtleties. First, the matcher excludes prefetch requests. Without that, App Router's client-side navigation prefetches will each get a fresh nonce, none of which match the nonce embedded in the eventually-rendered page. Second, setting the header on both the request (so Server Components can read it) and the response (so the browser enforces it) is redundant-looking but correct: the request header is app-internal state, the response header is the wire protocol.

Read the nonce in Server Components and next/script

Server Components can read the nonce from the forwarded request header. Because the nonce changes per request, any route that uses it must be dynamic. headers() forces that automatically, but keep it in mind when debugging why an unrelated route suddenly stopped being statically generated.

// app/layout.tsx
import { headers } from 'next/headers'
import Script from 'next/script'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const nonce = (await headers()).get('x-nonce') ?? undefined

  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"
          strategy="afterInteractive"
          nonce={nonce}
        />
        <Script id="gtag-init" strategy="afterInteractive" nonce={nonce}>
          {`window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'G-XXXXX');`}
        </Script>
      </body>
    </html>
  )
}

Notice the await headers(). As of Next.js 15, headers(), cookies(), and route params are async. That change caught a lot of us off guard; I wrote up the params-is-now-a-Promise migration when it hit our codebase. If you're still on 14, drop the await.

For inline scripts you write yourself (analytics init, feature flags snippet), always pass nonce. For third-party scripts loaded via <Script src="...">, the nonce prop is what lets strict-dynamic trust the eventual DOM insertions those scripts make. Skip it and every third-party script silently fails.

What strict-dynamic actually does

The 'strict-dynamic' keyword is the whole reason nonces are worth the effort. Without it, you need to enumerate every host your scripts might load from. Google Tag Manager loads GA, GA loads a dozen more, an A/B test tool loads its own bundle from a CDN you've never heard of. That allowlist is impossible to keep current, and one entry too permissive breaks the entire policy.

With 'strict-dynamic', the deal is different: scripts you nonce-authorize are trusted to load whatever they want. The browser propagates trust through the DOM. Your job shrinks from "enumerate every CDN" to "put a nonce on every <script> the framework emits." The MDN script-src reference has the formal semantics.

One caveat that catches everyone: when you enable 'strict-dynamic', host-based allowlist entries like https://cdn.example.com are ignored. Older browsers that don't understand 'strict-dynamic' will still honor them (that's the fallback), but modern browsers won't. So don't add hosts to script-src as a safety net expecting them to work in Chrome. They won't.

style-src: the inline styles problem

Nonces work for <script>. They don't work reliably for styles injected by client-side libraries. Framer Motion, Radix UI, Tailwind's JIT, and React itself all inject <style> tags at runtime without a nonce prop. You have three options, none perfect:

  1. Allow 'unsafe-inline' for styles. This is what nearly every production Next.js app I've shipped does. CSS-based XSS is rare and hard to weaponize, so the risk trade-off is usually acceptable.
  2. Hash every style block. Feasible for a fully server-rendered app with no runtime CSS-in-JS. Impossible if you use Radix, MUI, or any headless UI library.
  3. Use nonces and control every library. Only realistic if you author your own component system. Emotion, styled-components v6+, and vanilla-extract do accept a nonce; most others don't.
// The pragmatic style-src most apps land on
style-src 'self' 'unsafe-inline';
style-src-elem 'self' 'unsafe-inline';
style-src-attr 'unsafe-inline';

Static routes, PPR, and the nonce trap

Middleware runs on every request, but it does not run on statically generated HTML served from the edge cache. If a route is fully static (no cookies(), no headers(), no fetch() with dynamic options), Next.js will serve the same prerendered HTML to every user. That HTML has a nonce baked in from build time, and your middleware's freshly-generated nonce will not match. I hit this exact bug shipping analytics on a marketing site last quarter and spent an evening convinced middleware was broken.

Symptoms: intermittent CSP violations for scripts that were nonced correctly. It happens on the routes that are cheap enough to statically generate, often your marketing pages. Three fixes, ranked by preference:

  1. Force dynamic on any route that emits scripts with nonces. Add export const dynamic = 'force-dynamic' to the route segment, or call await headers() in the layout that renders the <Script> tags. Reading a header from middleware forces dynamic rendering automatically.
  2. Serve a separate policy for static routes. Configure a permissive policy (with 'unsafe-inline') via next.config.js headers, then override with the strict nonce-based policy in middleware. The middleware policy only lands on dynamic routes.
  3. Skip CSP on static routes entirely. If the static page has no user input surface, the XSS risk is low. This is the escape hatch for marketing-heavy sites.

Partial Prerendering (PPR), which is stable in Next.js 15, makes this trickier because the shell is static and the dynamic hole is streamed in. The nonce belongs to the streamed part, so make sure your <Script> tags are inside the dynamic Suspense boundary, not the static shell.

Rolling out with report-only

Never enable enforcement on the first deploy. Ship a Report-Only header first, watch violations for a week, fix them, then flip. The header name is Content-Security-Policy-Report-Only; browsers respect it exactly like the real one, except they log instead of block.

// In middleware.ts, during rollout:
response.headers.set('Content-Security-Policy-Report-Only', cspHeader)
// NOT 'Content-Security-Policy' yet

Add a reporting endpoint. Modern browsers support the Reporting-Endpoints header and report-to in the policy. For a self-hosted collector, a Route Handler works:

// app/api/csp-report/route.ts
export async function POST(request: Request) {
  const report = await request.json()
  // Ship to Sentry, Datadog, whatever
  console.log('[CSP]', JSON.stringify(report))
  return new Response(null, { status: 204 })
}

Then wire the endpoint in your middleware:

response.headers.set(
  'Reporting-Endpoints',
  'csp-endpoint="/api/csp-report"'
)
// And append to cspHeader: report-to csp-endpoint;

If you're already sending errors to Sentry, forward violations there. I covered the setup in the Next.js Sentry integration guide. Aggregating violations by directive and blocked-URI is what makes the rollout finite; without it you'll spend weeks chasing one-off browser extensions.

Common CSP violations and how to fix them

Some patterns come up in every rollout. Here's the shortlist:

eval() and new Function()

If a library uses eval, strict CSP blocks it. Common offenders: certain versions of Recharts, older MobX, Handlebars templates loaded at runtime. Fix: upgrade the library, or add 'wasm-unsafe-eval' if it's WebAssembly (that keyword is narrower than 'unsafe-eval' and only permits Wasm compilation).

Google Fonts and remote CSS

If you're loading fonts from Google Fonts directly (not via next/font), you need font-src fonts.gstatic.com and style-src fonts.googleapis.com. Switch to next/font and both problems disappear, since it self-hosts the fonts at build time. If you're new to the built-in helper, my next/font setup walkthrough covers the migration end to end.

Iframes for embeds

Adding YouTube, Stripe Checkout, or a Calendly widget? Each needs frame-src https://www.youtube.com, frame-src https://checkout.stripe.com, etc. And if the embed itself loads scripts, the CSP applies inside the iframe too, but that's the embed's problem, not yours, since iframes get their own document.

Browser extensions injecting scripts

You'll see violations from chrome-extension:// URIs. Ignore them. Some CSP report collectors filter these by default; if yours doesn't, filter on the source-file field.

Hot Module Replacement in development

In dev, Next.js uses eval for HMR. Either skip CSP in development, or add 'unsafe-eval' to script-src conditionally:

const isDev = process.env.NODE_ENV === 'development'
const cspHeader = `
  script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
  ...
`

Do not ship 'unsafe-eval' to production. It defeats a large part of the point.

Frequently Asked Questions

Where do you set CSP headers in Next.js?

For a nonce-based strict policy, set the Content-Security-Policy header in middleware.ts (or proxy.ts in Next.js 16), because middleware runs per request and can generate a fresh nonce each time. Static allowlist policies can go in next.config.js under async headers(), but they cannot include per-request nonces.

Does Next.js support strict-dynamic?

Yes. Next.js has no framework-level dependency on host allowlists, so script-src 'self' 'nonce-...' 'strict-dynamic' works out of the box as long as you pass the nonce to every <Script> component and any inline script tags in your layout.

Why is my nonce different in the header and the HTML?

Almost always because the route is being served from the static prerender cache while your middleware regenerates the nonce per request. Add await headers() to the layout, or set export const dynamic = 'force-dynamic' on the route segment, and the nonce will land in fresh HTML on each request.

What is a nonce in Content Security Policy?

A CSP nonce is a random, single-use token attached to the response header (e.g. script-src 'nonce-abc123') and to every allowed <script nonce="abc123"> tag. Only scripts carrying the matching nonce are permitted to execute; injected scripts without the nonce are blocked, which is how CSP defends against XSS.

Can I use CSP with Vercel deployments?

Yes. Vercel runs Next.js middleware on the edge before serving cached HTML, so per-request nonces work identically to a self-hosted deployment. The one thing to watch is that Vercel's ISR cache returns fully cached HTML for static routes, so nonces still won't apply there without forcing dynamic rendering.

Ben Howard
About the Author Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.