How to set up CSP reporting in Next.js App Router
Framework-specific setup for CSP reporting using middleware, route handlers, and the modern Reporting API.

You finally merged that strict Content Security Policy (CSP) to production, feeling like a security hero. You followed the tutorials, locked down your script-src, and even remembered to allow your analytics provider. Two hours later, your "Support" Slack channel is on fire because a third-party script used for customer chat is being blocked, but your server logs are silent. You're flying blind because you forgot the most critical part of a CSP rollout: a reporting endpoint that actually works across all browsers.
Implementing a CSP in Next.js is easy. Implementing a CSP that doesn't break your site and actually tells you why it broke is where most developers fail. Between the transition from the legacy report-uri directive to the modern Reporting API (report-to) and the nuances of Next.js middleware, it's easy to ship a policy that either reports nothing or accidentally DNoS-es your own API. This post provides a production-ready blueprint for capturing violations without the overhead of maintaining a custom ingestion pipeline.
The "Report-Only" Safety Net
If there is one rule in web security, it is this: never deploy a CSP in "enforce" mode first. A Content Security Policy is a blunt instrument. When a browser detects a violation, it doesn't just log a warning; it stops the execution of the offending resource immediately. In the world of the Next.js App Router, this usually means your app turns into a broken, hydration-error-riddled mess.
Breaking the App Router: Common CSP Pitfalls
Next.js relies heavily on inline scripts for hydration and data fetching. If you deploy a strict policy like script-src 'self', you will instantly break the framework. You'll see the dreaded "Text content does not match server-rendered HTML" errors because the scripts required to hydrate your components were never allowed to run. Furthermore, many third-party libraries (looking at you, Google Tag Manager) dynamically inject scripts that your static policy can't possibly predict.
Using Content-Security-Policy-Report-Only
To avoid a total site outage, you must use the Content-Security-Policy-Report-Only header. This header tells the browser: "Check these rules, log the violations to the console, and send a report to my endpoint—but don't actually block anything."
Compare these two headers. The first will break your site; the second will just tell you why it would have broken:
# Dangerous: Enforce mode
Content-Security-Policy: default-src 'self'; script-src 'self';
# Safe: Report-Only mode
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /api/csp-report;In your browser console, instead of a red error saying "Refused to load the script because it violates the following Content Security Policy directive," you will see a slightly gentler warning. This is your window of opportunity to fix your policy based on real-world data before you flip the switch to enforcement.
Implementation via Next.js Middleware
The standard way to add headers in Next.js is via next.config.js. This works for static headers, but it's fundamentally broken for a modern, strict CSP. Why? Because a truly secure CSP requires a "nonce" (a number used once).
Why next.config.js headers aren't enough
A nonce is a cryptographically strong random string generated on every single request. You add this nonce to your CSP header and to every <script> tag in your HTML. If a script doesn't have the matching nonce, the browser won't run it. This is the only reliable way to allow your own scripts while blocking malicious XSS injections.
Since next.config.js is evaluated at build time (or once at start-up), it cannot generate a new nonce for every request. You need Middleware.
Constructing a strict CSP string in middleware.ts
In your middleware.ts, you can generate a nonce, construct your CSP string, and pass it to your application. We use a header like x-nonce to pass the value from the middleware to your root layout, where you can then apply it to the <script> tags and the <meta> tags if needed.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// Define your policy
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
report-uri /api/csp-report;
report-to default;
`.replace(/\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy-Report-Only', cspHeader);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy-Report-Only', cspHeader);
return response;
}Note the report-uri and report-to directives at the end. This is where the magic (and the mess) begins.
The Messy State of Reporting: report-uri vs. report-to
If you've looked at any CSP documentation lately, you've likely seen a warning that report-uri is deprecated and you should use report-to. While this is true in the eyes of the W3C, the reality of browser support is much more complicated.
Why you still need legacy report-uri
Safari and Firefox do not currently support the new report-to directive. If you only provide report-to, you will receive zero reports from anyone using an iPhone or a Mac laptop running Safari. For a production app, this is an unacceptable blind spot. Therefore, you must use both.
Configuring the modern report-to group
The report-to directive is part of the newer Reporting API. It doesn't take a URL directly. Instead, it takes a "group name" that corresponds to a separate Reporting-Endpoints header. This is designed to allow the browser to batch reports and send them out-of-band, reducing the impact on your site's performance.
In your middleware, you need to add the Reporting-Endpoints header:
response.headers.set(
'Reporting-Endpoints',
'default="https://your-api.com/api/csp-report"'
);The syntax difference is jarring. report-uri is a simple space-separated list of URLs. report-to is a JSON-like string structure (often handled via the Reporting-Endpoints header in modern implementations). Getting these strings exactly right is a common source of bugs.
Building a Custom Route Handler (The Hard Way)
Once your headers are set, the browser will start POSTing violation data to your endpoint. Now you have to build something to catch it. In Next.js, this means creating an app/api/csp-report/route.ts.
Parsing the csp-report JSON payload
The first thing you'll notice is that the data format is inconsistent. Legacy report-uri sends a JSON object with a single top-level key: {"csp-report": { ... }}. The modern Reporting API sends an array of objects: [{ "type": "csp-violation", "body": { ... } }]. Your handler must account for both.
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const contentType = req.headers.get('content-type');
const body = await req.json();
// Handle legacy report-uri
if (body['csp-report']) {
const report = body['csp-report'];
console.log('CSP Violation (Legacy):', report['blocked-uri'], report['violated-directive']);
// Save to database
}
// Handle modern report-to (Reporting API)
else if (Array.isArray(body)) {
body.forEach((report) => {
if (report.type === 'csp-violation') {
console.log('CSP Violation (Modern):', report.body.blockedURL, report.body.effectiveDirective);
// Save to database
}
});
}
return NextResponse.json({ status: 'ok' });
} catch (e) {
return NextResponse.json({ status: 'error' }, { status: 400 });
}
}The Hidden Costs: Database bloat and report storms
Self-hosting this handler seems simple until you hit production. If you have a "report storm"—where a single broken script on a high-traffic page triggers a report from every single user—you can easily overwhelm your database. Each report is a few kilobytes of JSON. Multiply that by 10,000 users in an hour, and you're looking at significant storage and ingest costs. Furthermore, since this is a public endpoint, you have to protect it against spam and invalid payloads, adding even more complexity to your middleware.
Automating the Boilerplate with GlitchReplay
If you don't want to spend your weekend debugging JSON parsing logic for different browser versions, you can use a managed service. This is where GlitchReplay's CSP Report Endpoint tool becomes extremely useful. Instead of building your own ingestion pipeline, you can generate a dedicated, high-availability endpoint in seconds.
Using the CSP Report Endpoint Generator
The tool allows you to map your directives visually and generates the exact header strings you need for both report-uri and report-to. You don't have to worry about the specific JSON structure or the "Reporting-Endpoints" syntax; the tool handles the translation for you.
Decoupling reporting from your main app bundle
By using an external endpoint, you remove the performance hit of handling POST requests on your Next.js server. Your app can focus on serving users, while GlitchReplay handles the ingestion, deduplication, and storage of violation reports. This is particularly important on platforms like Vercel or Cloudflare Workers, where every millisecond of execution time costs money.
Integrating the generated header
Once you have your GlitchReplay endpoint, you simply update your middleware.ts to use the provided URL. You get a clean dashboard to view and search violations, making it much easier to identify patterns than digging through raw JSON logs in your own database.
Auditing and Refining Your Policy
Once the reports start rolling in, you'll realize that 90% of them are noise. This is the "refining" phase of the CSP rollout, and it's where the real security work happens.
Filtering out noise from browser extensions
One of the most frustrating aspects of CSP reporting is "false positives" from browser extensions. If a user has an ad-blocker, a password manager like LastPass, or a grammar tool like Grammarly, those extensions often inject scripts into your page. These injections will trigger CSP violations, even though there's nothing wrong with your code. In your reporting dashboard, you'll need to filter for common extension patterns (like chrome-extension:// or moz-extension://) to avoid chasing ghosts.
Identifying missing script-src domains
The remaining 10% of reports will be legitimate. You might find that your analytics provider is trying to load a tracking pixel from a different subdomain than you expected. Or perhaps a CSS file is trying to load a font from a CDN that you forgot to whitelist. Use these reports to surgically update your middleware.ts policy. For deeper analysis of these reports, you can use the CSP Violation Decoder to break down exactly what went wrong and which directive was violated.
The final flip: Moving to Enforce mode
After a few weeks in Report-Only mode, once the volume of legitimate violations has dropped to zero, it's finally time. You change the header name from Content-Security-Policy-Report-Only to Content-Security-Policy. Because you've spent the time auditing the data, you can do this with confidence, knowing that you aren't about to break the site for your users.
But keep the reporting endpoint active. A CSP isn't a "set and forget" feature. As you add new features, third-party libraries, or CDNs, your policy will need to evolve. Constant reporting ensures that when something does change, you're the first to know, not the last.
Setting up a CSP in Next.js is more than just adding a header; it's about building a feedback loop that protects your users without sacrificing the developer experience. If you're ready to stop guessing and start securing, check out our deep dive into nonces or jump straight to the CSP Report Endpoint tool to get your production-ready config in seconds.
GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.