Skip to main content
ASoc
Tutorial

A Next.js Content Security Policy That Keeps Static Rendering

The documented nonce recipe turns every route it touches dynamic. The static-safe policy we ship instead, what 'unsafe-inline' really costs, and what the header still blocks.

The ASoc Team10 min read

A Content Security Policy for a static Next.js site belongs in next.config.ts, not in middleware. The documented nonce recipe generates a fresh value per response, and a per-response value cannot be baked into a prerendered page — so every route it covers turns dynamic. On a marketing site that trades your whole static build for one header.

That sentence is the part of the CSP conversation that almost nobody writes down, and it is the reason our own policy looks different from every tutorial you will find.

The recipe everyone copies, and what it costs

The Next.js CSP guide shows a nonce generated in middleware, injected into the Content-Security-Policy header, and read back in a Server Component. It is correct, it is genuinely more secure, and it is the right answer for an authenticated application.

It is also load-bearing in a way the guide states only in passing: a nonce must be unique per response. Anything unique per response cannot be part of a prerendered HTML file. Middleware runs on every matched request, the page has to read that request's nonce, and the route falls out of the full-route cache.

For a dashboard behind a login, that costs nothing — those routes were already dynamic. For a storefront, a docs site, or a landing page, it is the difference between a file served from a CDN edge and a function invocation.

Nonce policy (middleware)Static policy (next.config.ts)
Where it livesmiddleware.ts, per requestasync headers(), build-time constant
RenderingForces dynamic on every matched routeRoutes stay static (SSG)
Served fromFunction invocationCDN edge / static file
script-src'nonce-…' 'strict-dynamic''self' 'unsafe-inline' + named hosts
Blocks injected inline <script>YesNo
Blocks injected external scriptYesYes
Blocks framing / clickjackingYesYes
Right forApps whose routes are already dynamicStatic marketing and content sites

The row that decides it is not the script-src row — it is the second one. Pick the policy that matches how your routes render, then be explicit about what the weaker one still buys you.

The static-safe policy

This is the whole thing, from our own next.config.ts. It ships on every route of a 110-product storefront that is otherwise entirely static.

// next.config.ts
const contentSecurityPolicy = [
  "default-src 'self'",
  `script-src 'self' 'unsafe-inline'${scriptSrcEval} https://app.lemonsqueezy.com https://assets.lemonsqueezy.com https://va.vercel-scripts.com`,
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: blob: https:",
  "font-src 'self' data:",
  `connect-src 'self' ${supabaseOrigin}`,
  "frame-src 'self' https://*.lemonsqueezy.com https://*.vercel.app",
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self' https://*.lemonsqueezy.com",
  "object-src 'none'",
].join("; ");

const securityHeaders = [
  { key: "Content-Security-Policy", value: contentSecurityPolicy },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "X-Frame-Options", value: "DENY" },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains",
  },
];

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

headers() runs at build time and produces a constant. No middleware, no per-request work, no route opts out of static rendering.

What 'unsafe-inline' is paying for

Three inline scripts you cannot delete without giving up something you want more:

  1. Next.js's own hydration runtime. The self.__next_f.push([...]) chunks that stream the RSC payload are inline by construction.
  2. The theme bootstrap. A blocking inline script in app/layout.tsx that reads the saved theme and sets the class on <html> before first paint. Move it to an external file and you reintroduce the flash of wrong theme — the exact problem it exists to solve.
  3. Tailwind's emitted inline <style>. Hence 'unsafe-inline' on style-src too.

You could nonce all three. You would then be paying dynamic rendering on every page to defend against an injection vector that, on a site with no user-generated HTML, has no entry point.

What the policy still blocks

This matters, because "we allow unsafe-inline" reads like "we have no CSP". It is not the same thing:

  • External script injection. An attacker who gets a <script src="https://evil.example/x.js"> into your HTML gets a console error, not execution. script-src names four origins and nothing else reaches the network.
  • Clickjacking. frame-ancestors 'none' means no one can put your checkout in an invisible iframe.
  • Plugin and object embeds. object-src 'none'.
  • Base-tag hijacking. base-uri 'self' stops an injected <base> from repointing every relative URL on the page.
  • Form exfiltration. form-action limits where a form can post — an injected form cannot ship credentials to an attacker's endpoint.

Four of those five are unaffected by the unsafe-inline decision. The honest summary is that a static policy gives up one class of defense and keeps the rest, and the tutorials that present CSP as all-or-nothing are what make teams ship no policy at all.

Derive hosts, do not hardcode them

connect-src needs your backend's origin, and that origin differs per environment. Reading it from the same variable the client reads means the policy is correct in preview, production and local dev without a branch:

const supabaseOrigin = (() => {
  try {
    return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
  } catch {
    return "https:";
  }
})();

The fallback is deliberate. A build with the variable unset should not crash the config, and it should not silently emit a broken directive either.

Dev needs 'unsafe-eval'. Production must not have it.

Turbopack's Fast Refresh evaluates modules with eval. next start does not. Gate it:

const scriptSrcEval =
  process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : "";

This is the single most common way a good policy gets quietly weakened: someone hits EvalError in dev, adds 'unsafe-eval' to the shared string, and ships it.

One rule for the allowlist: no speculative hosts

Every origin in the policy above is traceable to a line of code that loads it. When we audited ours, one entry that "obviously" belonged turned out not to. Vercel Web Analytics v2 is documented against va.vercel-scripts.com, so the instinct is to add it to connect-src as well as script-src. Reading the package source showed the beacon posts to a first-party path on the site's own origin — already covered by 'self'. The extra host would have been permanent, invisible, and pure attack surface.

Write the reason next to each entry in a comment. A year from now the comment is the only thing standing between your allowlist and a wildcard.

Verifying it

Three checks, in order of how much they tell you:

# 1. Is the header actually on the response?
curl -sI https://your-site.example/ | grep -i content-security-policy

# 2. Deploy it as report-only first and watch for a week.
#    Same value, different header name — violations are logged, nothing breaks.
Content-Security-Policy-Report-Only: <same policy>

# 3. Open the site with the console visible and click through
#    every third-party surface: checkout, embeds, analytics.

Step 3 is the one people skip, and it is where real breakage lives — a payment overlay, an embedded demo iframe, a font that turns out to be remote. Automated scanners grade the policy; only a browser tells you whether the site still works under it.

The other reason to run report-only first: a CSP failure is silent to the user. The page renders, the button does nothing, and nobody files a bug.

When you should take the nonce route

Use the middleware nonce recipe when the pages it covers are already dynamic — an authenticated dashboard, a checkout flow, anything rendering per-user data. You lose nothing you had.

A hybrid also works and is underused: scope the middleware matcher to your app routes and leave the marketing and content routes on the static header. Two policies, each matched to how its routes render. The cost is that two policies now have to be kept in sync, so only do it if the app half is a meaningful part of the site.

Mistakes and how they show up

MistakeWhat happensFix
Nonce middleware on a static siteEvery route goes dynamic; CDN caching lostBuild-time header in next.config.ts
'unsafe-eval' left in the shared stringProduction policy permanently weakenedGate on NODE_ENV
Hardcoded backend originPolicy correct in prod, broken in previewDerive from the public env var
Adding hosts "just in case"Allowlist grows, nobody can audit itOne origin per line of code that loads it
No frame-ancestorsClickjacking still possible with a CSP presentframe-ancestors 'none'
Skipping report-onlySilent breakage in checkout, found by customersShip report-only for a week first
default-src 'self' onlyDirectives that do not fall back stay unsetSet base-uri, form-action, object-src explicitly
HSTS with preload on day oneHard to reverse before every subdomain is HTTPSAdd preload at go-live, then submit
Testing with a scanner onlyGrades the string, not the siteClick through every third-party surface

Frequently asked questions

Does a CSP with 'unsafe-inline' do anything at all? Yes — it blocks external script loading, framing, object embeds, base-tag hijacking and form exfiltration. What it does not block is an injected inline <script>. If your site has no path by which attacker-controlled HTML reaches the page, that gap is theoretical while the other five protections are not. Be precise about which one you are trading, rather than treating CSP as a single switch.

Why both frame-ancestors and X-Frame-Options? frame-ancestors is the modern control and takes precedence where supported; X-Frame-Options: DENY is the fallback for older clients. They say the same thing, so keeping both costs one header and removes a browser-version question.

Should I add preload to HSTS? Not on day one. preload only takes effect after you submit the domain to the browser preload list, and removal takes months. Ship max-age with includeSubDomains immediately; add preload and submit once every subdomain is confirmed HTTPS-only.

Does img-src https: defeat the point? It is a deliberate loosening for sites that render remote images — product art, avatars, CDN assets. It permits any HTTPS image origin but still blocks http: and any non-image use. If all of your images are local, tighten it to 'self' data: and enjoy the stricter policy.

Where do security headers go on a non-Vercel host? async headers() works anywhere Next.js serves the response. If a CDN or reverse proxy terminates first, set them there instead of in both places — duplicate CSP headers are intersected by the browser, which produces confusing failures that look like the app is broken.

Templates with the security work already done

Every template on this site ships under the header configuration above, so the policy is not something you retrofit after launch.

If you are marketing a security product, three of them are built for exactly that story. ASoc Guard is an AI cyber-security platform site with a live-metrics hero, three protection pillars and a security-console preview. ASoc Sentinel markets a consumer security suite — malware, VPN and identity protection with a live protection-status hero and tiered plans. ASoc Aegis is a GRC and risk-management site built around a Risk Center dashboard preview and a three-tier comparison pricing table.

Browse the full set of Next.js landing page templates or the Tailwind landing page templates. For the rendering-strategy question underneath all of this — which routes are static and which are not — the App Router vs Pages Router comparison covers where the boundary falls.

Keep reading

Tutorial10 min read

Next.js Currency Formatting: Four Renderings, One That Lied

Four ways to print money in one codebase, and the fallback that stamped a dollar sign on any currency Intl could not parse. The audit, the fix, and the locale rule.

Read more