DEV Community

ushiro
ushiro

Posted on

`next dev` Renders but Nothing Works: Your CSP Is Missing `unsafe-eval`

I run AI Change Watch, a small independent project that
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing
and SDK releases — and records every time one of them changes.

At some point I added a Content-Security-Policy. It was correct. It shipped. Production was fine.

And then, locally, every interactive thing on the site stopped working.

The symptom

next dev starts. The page loads. It looks exactly right — the layout, the data, the styles, all
of it. Then:

  • the search box accepts text and filters nothing
  • the sort headers don't sort
  • the "show more" button does nothing
  • no onClick anywhere fires

No error page. No red overlay. No failed request in the Network tab. The server rendered the HTML and
sent it, so the page you are looking at is real — it is just completely inert. Nothing hydrated.

If you have not hit this before, the natural first guess is your own component. That is where I went,
and it is the wrong place, because every component is fine.

The one line that names it

The console has it, but you have to be looking:

Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an
allowed source of script in the following Content Security Policy directive:
"script-src 'self' 'unsafe-inline' …"
Enter fullscreen mode Exit fullscreen mode

And the reason it is easy to miss is that it is not a JavaScript error. It does not have a stack. It
does not point at your file. It appears once, near the top, above whatever else the page logged, and it
names a directive rather than a component.

Why this happens in dev and not in prod

Next's development server compiles modules and hands them to the browser wrapped in eval — that is
how the dev devtool setting works, and it is what React Refresh needs to swap a component without
reloading the page. Fast Refresh is built on it.

A production build does not do that. next build emits static chunks. There is no string being
evaluated at runtime, so there is nothing for 'unsafe-eval' to permit.

Which produces the trap:

The CSP is correct for production and fatal in development — and development is where you spend all
your time.

You will not catch it in CI, because CI builds. You will not catch it in preview, because preview
builds. You catch it the moment you try to click something locally, and by then you are three commits
into a feature and looking for the bug in your own diff.

Why the header reaches dev at all

Because it is in next.config, and that file has no idea which mode it is running in unless you tell
it:

// next.config.mjs
async headers() {
  return [{ source: '/:path*', headers: securityHeaders }];
},
Enter fullscreen mode Exit fullscreen mode

source: '/:path*' means every path. There is no dev/prod branch, so next dev serves the same header
next start does. That is a reasonable default — you generally want to develop against the headers
you ship — it just happens to be wrong for this one directive.

Two fixes, and they are not equivalent

Option A — widen the policy in development only.

const isDev = process.env.NODE_ENV === 'development';

const csp = [
  "default-src 'self'",
  // 'unsafe-eval' is DEV-ONLY: the dev server evaluates compiled modules as strings (that is what
  // React Refresh is built on), and a production build never does. Shipping it would be a real
  // widening of the policy for zero benefit.
  `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''}`,
  // The HMR socket, same reasoning. In production nothing connects back to the dev server.
  `connect-src 'self'${isDev ? ' ws: wss:' : ''}`,
  // …the rest
].join('; ');
Enter fullscreen mode Exit fullscreen mode

The comment is not decoration. A conditional in a security header is exactly the kind of line that gets
"cleaned up" six months later by someone who reads it as an inconsistency, so the reason it is
conditional has to sit next to it.

Option B — stop testing client behaviour in next dev.

next build && next start
Enter fullscreen mode Exit fullscreen mode

This is what I actually do, for a reason that has nothing to do with CSP: this project deploys to
Cloudflare Workers through OpenNext, and next dev is not the runtime it ships on. Behaviour I verify
in dev is behaviour I verified somewhere the code will never run. So for anything client-side I build
and serve the real thing.

The cost is real — you lose Fast Refresh, and a rebuild per change is slow enough to change how you
work. If your production runtime is Node, Option A is the better trade. If it isn't, Option B was
going to be necessary anyway and this just makes it obvious sooner.

The general shape

The thing worth taking away isn't the directive. It's this:

A security header set in next.config applies to the dev server, and the dev server has different
requirements than the thing you deploy.

unsafe-eval is the one that produces a silent failure, which is why it costs the most time. But the
same category catches you elsewhere:

directive what dev needs that prod doesn't
script-src 'unsafe-eval' for the module runtime / React Refresh
connect-src ws: / wss: for the HMR socket
style-src 'unsafe-inline' if your prod build extracts CSS but dev injects it

If you are about to add a CSP to a Next app, the fastest check is not a code review. It is:

  1. next dev
  2. open the page
  3. click something
  4. read the console — the first line, not the last

Thirty seconds, and it is the only test that distinguishes "rendered" from "working". Everything else
about a dead page looks identical to a live one.


The tracker this came out of is at aichangewatch.com — it watches AI
vendor docs for changes. Its CSP still has no 'unsafe-eval' in production, which is the point.

Top comments (0)