This is version control

The Button Was Fine. CSP Had Other Plans.

A production CSP blocked Next.js hydration, leaving a perfectly rendered button with nothing useful to do.

technicalweb-securitycspnextjs2026.09.12
REFERENCE IMPLEMENTATION
tsx

The button that needed hydration

"use client";

import { useState } from "react";

export function RetryLab() {
  const [attempts, setAttempts] = useState(0);

  return (
    <button onClick={() => setAttempts(attempts + 1)}>
      retry
    </button>
  );
}

The component was small and behaved correctly in isolation. The failure happened when the deployed browser tried to hydrate it.

THE HEADER CHANGE
http

The CSP before and after

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';

# Working compromise for the static export
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline';

The second policy restores Next.js hydration, but it also broadens the allowed script behavior. That tradeoff should be explicit.

PRODUCTION CHECK
typescript

Test the deployed browser boundary

await page.goto(
  "https://thisisversioncontrol.dev/notes/why-your-retry-logic-is-lying-to-you/"
);

await page.getByRole("button", { name: "retry" }).click();

await expect(page.getByText("#2")).toBeVisible();

This checks the generated HTML, response headers, client bundles, and hydration together. It is a different contract from the component test.

The labs worked locally.

A retry button advanced through its schedule. A slider moved between a lean cloud setup and one with more headroom. The tests passed. The production build passed.

Then the site went live, and the interactive parts stopped being interactive.

The HTML was there. The headings rendered. The button appeared. The browser simply declined to make any of it useful.

The suspiciously competent bug

The lab is a small Next.js client component. Nothing exotic: no API call, no browser-specific dependency, no complicated state machine. Locally, it hydrated and worked.

The production page contained the expected server-rendered button markup.

That was the clue. The component had rendered. The browser had received the button. It had not received the JavaScript behavior that turns the button from furniture into software.

Enter Content Security Policy

The site had a restrictive policy that allowed scripts from the site itself but not inline scripts.

At first glance, this looks sensible. Scripts can come from the site itself, styles can come from the site itself, and random third-party resources are not invited in for snacks.

The important detail is that "script-src 'self'" does not allow inline scripts.

Next.js static output includes external JavaScript files, but it also emits inline bootstrap scripts. Those scripts carry information the browser needs to connect the server-rendered HTML to the client-side React application.

The browser saw external Next.js chunks as well as inline bootstrap scripts. The external chunks were allowed; the inline bootstrap was blocked.

The external script was allowed. The inline script was blocked. The result was a page that looked alive but had no pulse.

The console had the useful, blunt version of the story: "Refused to execute inline script because it violates the Content Security Policy directive script-src 'self'."

The browser was not being difficult. It was following instructions.

Why localhost passed

Local development did not have the same security boundary. The development server and production hosting differed in several important ways:

localhost used the Next development server. Production used static files, Amplify response headers, browser-enforced CSP, and cached deployment artifacts.

The component code was not the problem. The environment was.

This is the useful distinction:

Does the component work?

is not the same question as:

Does the deployed browser allow the component to hydrate?

The first question was answered by the unit tests. The second required opening the real site.

The fix and its tradeoff

The immediate fix was to allow the inline bootstrap scripts. That restored hydration, but "unsafe-inline" weakens the policy.

If an attacker found a way to inject HTML into the page, inline JavaScript would have fewer obstacles.

That does not mean the site is automatically compromised. It does mean the security boundary is broader than it was before.

A stricter policy could allow only known inline scripts with a hash, or use a nonce attached to the permitted script.

Those approaches are easier when a server generates the response and the nonce or hash can be managed alongside the script. A pure static export makes the problem more deliberate, because there is no request-time server handing out fresh nonces.

The real choice was not "security or no security." It was a deployment tradeoff: strict CSP with broken hydration, or working hydration with broader inline-script permission.

The important part is knowing which tradeoff you made.

The test that was missing

The component test was still valid: clicking the retry button changed the displayed attempt from one to two.

It proved that the component behaved correctly when React was running. It did not prove that React could start in production.

A browser-level check would test the deployed boundary too. The reference block above uses the real production URL, clicks the control, and verifies the updated state.

That test exercises the generated HTML, the response headers, the client bundles, and the browser together. It is not redundant with the unit test. It is testing a different contract.

The actual lesson

Security headers are executable configuration. They are not documentation and they are not suggestions. They are instructions handed directly to the browser.

A CSP can protect a site and break a site in the same afternoon. Both things can be true.

The browser did not misunderstand the application. It followed the policy precisely.

The application did not necessarily fail. The deployment contract was incomplete.

So, in conclusion: always test in dev.

Also test the production build. Test the actual deployed URL, with the actual headers, in the actual browser, before discovering that your interactive lab has become a very polished screenshot.