I’ve seen a lot of teams ship a Content Security Policy in Next.js, feel good for a day, then quietly loosen it with 'unsafe-inline' and broad host allowlists because SSR made everything awkward.

That usually happens when a React app grows into a real product: analytics, consent banners, A/B testing, third-party embeds, and a pile of server-rendered pages under the App Router. CSP stops being a checkbox and turns into an operational problem.

Here’s a real-world case study pattern I’ve used to clean this up in a Next.js App Router app.

The setup

The app was a typical modern SaaS frontend built with:

  • Next.js App Router
  • Server Components by default
  • a few Client Components
  • Google Tag Manager
  • Google Analytics
  • Cookiebot
  • some internal API calls
  • WebSocket connections for realtime updates

The team had already tried to add CSP in next.config.js:

// next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self' https://api.example.com",
      "object-src 'none'",
    ].join('; '),
  },
]

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ]
  },
}

Looks reasonable at first glance. It was not.

What broke

The app rendered fine in local development, but production had inconsistent CSP violations:

  • inline framework scripts blocked
  • hydration edge cases
  • GTM sometimes loaded, sometimes didn’t
  • consent scripts injected by Cookiebot failed
  • a few routes worked, others didn’t
  • the team added 'unsafe-inline' to stop the bleeding

That fixed the symptoms and wrecked the point of having a script policy.

The core issue was simple: SSR with Next.js App Router needs per-request nonces if you want a strict CSP without falling back to 'unsafe-inline'.

A static header in next.config.js cannot generate a fresh nonce per request.

The “before” state

This was effectively the policy direction they had ended up with:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval'
    https://www.googletagmanager.com
    https://www.google-analytics.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  object-src 'none';

This is the classic compromise policy:

  • 'unsafe-inline' because inline scripts were blocked
  • 'unsafe-eval' because someone hit a dev-mode issue and copied a fix into prod
  • broad script hosts because third-party tags were loaded directly
  • no nonce strategy
  • no base-uri, form-action, or frame-ancestors

That policy was technically “a CSP header,” but not one I’d trust.

Why App Router changes the approach

With the App Router, you’re often rendering on the server and streaming HTML. Next.js can attach nonces to framework-generated scripts, but only if you provide a nonce on the request/response path and wire it through correctly.

That means the right place to start is middleware, not next.config.js.

You need to:

  1. generate a nonce per request
  2. build the CSP with that nonce
  3. attach the nonce to request headers so the app can read it
  4. send the final CSP response header
  5. use that nonce for any inline scripts you explicitly render

The fix: nonce-based CSP in middleware

Here’s the pattern.

middleware.ts

import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

function makeNonce() {
  return crypto.randomBytes(16).toString('base64')
}

export function middleware(request: NextRequest) {
  const nonce = makeNonce()

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
    "style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.example.com wss://ws.example.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com",
    "frame-src 'self' https://consentcdn.cookiebot.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'",
  ].join('; ')

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })

  response.headers.set('Content-Security-Policy', csp)
  return response
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

A few opinionated notes:

  • I exclude _next/static and image paths because they don’t need this processing.
  • I keep style-src 'unsafe-inline' if a library forces it. I don’t love it, but I’d rather be honest than pretend every production app can avoid it immediately.
  • I use 'strict-dynamic' when I trust nonce-bearing bootstrap scripts to load dependencies. That’s a much better model than endlessly expanding host allowlists.

Reading the nonce in App Router

Now the app needs access to the nonce.

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
          id="gtm-init"
          nonce={nonce}
          strategy="afterInteractive"
        >
          {`
            window.dataLayer = window.dataLayer || [];
            window.dataLayer.push({ event: 'page_view' });
          `}
        </Script>
      </body>
    </html>
  )
}

That nonce prop matters. If you render inline script without it, CSP will block it.

For third-party script tags:

<Script
  src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
  strategy="afterInteractive"
  nonce={nonce}
/>

The “after” state

The final production policy looked a lot more like a real hardened deployment. A good reference is the live header used by HeaderTest:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-YmYyOTM5YjYtMjlkYi00MTljLWEzY2YtOWRmN2Y4NDhkNGQx' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'

That’s what a grown-up CSP usually looks like: specific, nonce-based, shaped around actual runtime dependencies, and not pretending third-party scripts don’t exist.

If you want to inspect headers on a live site, HeaderTest is handy for quickly verifying what actually ships, not what you think ships.

What changed in practice

After moving from static headers to nonce-based middleware:

  • inline script violations dropped to zero
  • GTM and Cookiebot loaded consistently
  • the team removed 'unsafe-eval'
  • they stopped opening script-src every time marketing added a tag
  • SSR routes and client navigation behaved the same way
  • the CSP became something they could maintain

That last point matters more than the syntax. A CSP nobody can operate is dead on arrival.

Common App Router mistakes

I keep seeing the same bugs.

1. Generating one nonce at build time

Wrong:

const nonce = crypto.randomBytes(16).toString('base64')

in a module that loads once.

That gives you a reused nonce across requests. Nonces must be per response.

2. Setting CSP only in next.config.js

Fine for static policies. Not fine for nonce-driven SSR.

3. Forgetting the nonce on inline <Script>

If the script is inline, give it the nonce or expect a violation.

4. Testing only in dev

Next.js dev mode behaves differently and may require looser settings. Validate CSP in production builds.

5. Keeping stale host allowlists with 'strict-dynamic'

If you’re using nonces and 'strict-dynamic', old host-based assumptions may not behave the way your team expects. Know why each source is there.

A practical rollout plan

If your current policy is loose, don’t try to jump straight to perfect.

I’d do this:

  1. add middleware-based nonce generation
  2. ship Content-Security-Policy-Report-Only
  3. collect violations
  4. fix your own inline scripts first
  5. explicitly map third-party dependencies
  6. enforce once noise drops

For policy templates, csp-examples.com is useful when you need a sane starting point and don’t want to handcraft every directive from scratch.

The real lesson

The big lesson from this case wasn’t “use nonces.” It was this:

CSP in Next.js App Router is a rendering concern, not just a headers concern.

Once a team understands that, the implementation gets much cleaner. You stop fighting React, stop cargo-culting CSP strings into config files, and start treating policy generation as part of the request lifecycle.

That’s the difference between a CSP that exists and a CSP that actually protects anything.