Setting up Keystatic on this site broke on a subtle Next.js behaviour: when two headers() rules both match the same route, Next emits both sets of headers on the response. For most headers that's harmless. For Content-Security-Policy it is not.
The setup that looked fine
Two policies, one scoped to the Keystatic dashboard, one for the public site:
async headers() {
return [
{ source: "/keystatic/:path*", headers: keystaticHeaders },
{ source: "/api/keystatic/:path*", headers: keystaticHeaders },
{ source: "/(.*)", headers: publicHeaders },
];
}
The intent reads cleanly: dashboard gets the relaxed CSP, everything else gets the strict one. In practice, every request to /keystatic matches both the first rule and the /(.*) catch-all, so the response carries two Content-Security-Policy headers.
Why two headers is worse than one wrong one
From the CSP spec:
If the response contains multiple CSP headers, the user agent enforces the intersection of all policies.
Intersection means most restrictive wins. The strict public CSP blocks api.github.com, so the Keystatic dashboard's client-side React app silently fails to reach GitHub's GraphQL endpoint and renders a blank page. No error toast. Just nothing.
The giveaway in DevTools:
Connecting to 'https://api.github.com/graphql' violates the following Content Security Policy directive: "connect-src 'self' https://cloud.umami.is"
That connect-src is from the public policy — proof both headers were on the wire.
The fix
Exclude the Keystatic paths from the catch-all using a negative lookahead in the source pattern:
{ source: "/((?!keystatic|api/keystatic).*)", headers: publicHeaders }
Now /keystatic only matches the specific rule, and the response carries exactly one CSP header.
What I'd check in your config
- Run
curl -sI https://your-site.com/keystatic | grep -i content-security-policy— if two lines come back, you have the same bug. - Any time you add a second
headers()rule whosesourcecould overlap with an existing one, think about whether the header keys conflict.
The broader lesson: CSP failures are usually silent on the client and invisible on the server. If a page that loads fine locally goes blank in production, check the response headers before anything else.