DEV Community

Anas Sheikh
Anas Sheikh

Posted on

You Added a Content-Security-Policy Header and Your Next.js App Went Blank. Here's Why.

You read that a Content-Security-Policy header is a good security hardening step. You add a sensible-looking policy to your Next.js config, deploy, and the site loads as a blank or half-working page with a wall of red errors in the console.

Nothing is wrong with your code. The policy is doing exactly what you told it to do, and what you told it to do happens to block Next.js itself.

The Header That Breaks Everything

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self'",
          },
        ],
      },
    ];
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

script-src 'self' says scripts may only load from your own origin as external files. That sounds right, until you remember Next.js injects small inline <script> tags into every page, for hydration data, for the router, for streaming. Inline scripts are exactly what script-src 'self' forbids, so the browser refuses to run them, hydration never completes, and the page looks dead.

The Tempting Wrong Fix

value: "default-src 'self'; script-src 'self' 'unsafe-inline'"
Enter fullscreen mode Exit fullscreen mode

This makes the errors disappear, and it also removes most of the protection you added the header for. 'unsafe-inline' tells the browser to run any inline script, including one an attacker managed to inject through an XSS hole. A CSP with 'unsafe-inline' in script-src still exists, but it no longer does the main job a CSP is supposed to do.

The Actual Fix: A Per-Request Nonce

A nonce is a random, single-use value generated for each request. You put it in the CSP header, and you attach the same value to the scripts you trust. The browser then only runs inline scripts carrying the matching nonce, and an injected script has no way to guess it.

Next.js supports this through middleware:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
  ].join('; ');

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  requestHeaders.set('Content-Security-Policy', csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('Content-Security-Policy', csp);
  return response;
}
Enter fullscreen mode Exit fullscreen mode

Next.js reads the nonce from the request's CSP header and applies it to the framework's own inline scripts automatically. For any script you add yourself, read the nonce and pass it along:

// 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://analytics.example.com/script.js" nonce={nonce} strategy="afterInteractive" />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

'strict-dynamic' in the policy lets a nonced script load further scripts it depends on, which keeps things like analytics loaders working without you allowlisting every domain by hand.

The Tradeoff Nobody Mentions Upfront

A nonce has to be unique per request, and it has to be baked into the HTML the server sends. That means any page using it must be rendered dynamically on each request. A statically generated page is built once ahead of time, so it cannot contain a fresh nonce for every visitor.

This is the same static versus dynamic rendering boundary covered in my earlier posts on cookies() and searchParams. Reading the nonce from headers() opts the route into dynamic rendering, so turning on nonce-based CSP across your whole site gives up static generation for every route it covers.

For a mostly static marketing site, that is a real cost. Two reasonable ways to handle it:

Apply the strict nonce policy only where it matters most, like authenticated dashboards and anything handling user input, and use a hash-based or more permissive policy for purely static pages.

Use hash-based CSP for static pages, where the script contents are known at build time, so each inline script's hash can be allowlisted instead of requiring a per-request nonce.

How to Test It Without Breaking Production

// Ship this first, it reports violations without blocking anything
{
  key: 'Content-Security-Policy-Report-Only',
  value: "default-src 'self'; script-src 'self' 'nonce-...'",
}
Enter fullscreen mode Exit fullscreen mode

Content-Security-Policy-Report-Only logs every violation to the console and to a reporting endpoint without actually blocking a thing. Run it for a while, fix what it flags, then switch to the enforcing header. This is how you avoid the blank-page deploy from the top of this post.

The Actual Rule

A strict CSP and Next.js's inline scripts are incompatible unless you use nonces or hashes, and nonces mean dynamic rendering. Do not reach for 'unsafe-inline' to make errors go away, since that removes the protection you were adding. Start in report-only mode, decide which routes genuinely need the strictest policy, and accept that those routes will render per request.

I go through this same tradeoff when hardening client projects and the templates at pixelanas.com, and I write up more of these security and rendering details on the blog as I run into them.


If you have a CSP on a Next.js app right now, check whether it contains 'unsafe-inline' in script-src. If it does, it is worth knowing how much protection it is really giving you. Drop what you find in the comments.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)