Next.js developers usually think about CSP at the page level: block inline scripts, add nonces, lock down third-party tags. That’s the right instinct, but API routes deserve attention too.
Strictly speaking, CSP is mostly a browser-enforced policy for documents and some subresources. Your JSON API endpoint isn’t executing scripts in the browser, so a Content-Security-Policy header on /api/users won’t do much for a normal application/json response.
That’s the part people skip.
The useful pattern is this:
- Use CSP on any API route that can return HTML, preview content, error pages, or file responses rendered by the browser.
- Pair CSP with other headers on JSON APIs:
Content-Type,X-Content-Type-Options,Cross-Origin-Resource-Policy,Cache-Control, and sometimes CORS. - Keep policy generation consistent across pages, route handlers, and middleware.
If you run a Next.js app with mixed behavior — normal pages, API JSON, auth callbacks, preview endpoints, dynamically generated documents — you want a clean strategy instead of random header snippets everywhere.
When CSP matters for API routes
A few cases where CSP on API routes is actually worth setting:
- API route returns HTML for previews or embeds
- Route streams HTML
- Route returns SVG or other browser-rendered content
- Route serves downloadable files that might be interpreted inline
- Route is an error handler that falls back to HTML
- Route generates a page-like response outside the normal page rendering path
A plain JSON response like this doesn’t really benefit from CSP:
// pages/api/user.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ id: 1, name: 'Ada' })
}
For this kind of endpoint, I care more about making sure the browser won’t MIME-sniff it as something else.
// pages/api/user.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
res.status(200).json({ id: 1, name: 'Ada' })
}
That’s already a solid baseline.
A reusable CSP builder
If you have any route handlers returning browser-consumable content, build CSP in one place.
Create a helper:
// lib/csp.ts
export function buildCsp(options?: { nonce?: string }) {
const nonce = options?.nonce
const directives = [
`default-src 'self'`,
nonce
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
: `script-src 'self'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
]
return directives.join('; ')
}
This is intentionally boring. Boring is good for security headers.
If you need a policy template to start from, csp-examples.com is handy for comparing real-world directive combinations.
Pages Router API routes
For pages/api/*, you set headers directly on res.
Here’s an endpoint that returns HTML safely:
// pages/api/preview-card.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import crypto from 'node:crypto'
import { buildCsp } from '@/lib/csp'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const nonce = crypto.randomUUID()
const csp = buildCsp({ nonce })
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Content-Security-Policy', csp)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader('Cache-Control', 'no-store')
const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Preview</title>
<style nonce="${nonce}">
body { font-family: sans-serif; padding: 2rem; }
</style>
</head>
<body>
<h1>Secure preview</h1>
<script nonce="${nonce}">
console.log('Inline script allowed by nonce')
</script>
</body>
</html>
`
res.status(200).send(html)
}
Two things matter here:
- The response is actually HTML, so CSP applies.
- The nonce in the header matches the nonce in inline
<script>and<style>tags.
If you don’t control both sides, nonces become fragile fast.
App Router route handlers
In the App Router, use NextResponse or the standard Response.
// app/api/preview-card/route.ts
import crypto from 'node:crypto'
import { NextResponse } from 'next/server'
import { buildCsp } from '@/lib/csp'
export async function GET() {
const nonce = crypto.randomUUID()
const csp = buildCsp({ nonce })
const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Preview</title>
<style nonce="${nonce}">
body { font-family: system-ui; padding: 24px; }
</style>
</head>
<body>
<h1>Route Handler HTML</h1>
<script nonce="${nonce}">
console.log('CSP works here too')
</script>
</body>
</html>
`
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': csp,
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
},
})
}
For plain JSON, I usually skip CSP and keep the response tight:
// app/api/user/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json(
{ id: 1, name: 'Ada' },
{
headers: {
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
'Cross-Origin-Resource-Policy': 'same-origin',
},
}
)
}
Applying headers consistently with middleware
If your app has lots of routes, middleware can set defaults. I would not blindly attach CSP to every /api/* path, though. That usually creates noise without real benefit.
A better approach is to target HTML-producing routes or special API endpoints.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'node:crypto'
import { buildCsp } from '@/lib/csp'
export function middleware(req: NextRequest) {
const res = NextResponse.next()
// Baseline protections for API routes
if (req.nextUrl.pathname.startsWith('/api/')) {
res.headers.set('X-Content-Type-Options', 'nosniff')
res.headers.set('Referrer-Policy', 'no-referrer')
res.headers.set('Cross-Origin-Resource-Policy', 'same-origin')
}
// CSP only for routes that may render HTML
if (
req.nextUrl.pathname === '/api/preview-card' ||
req.nextUrl.pathname === '/api/embed'
) {
const nonce = crypto.randomUUID()
res.headers.set('x-nonce', nonce)
res.headers.set('Content-Security-Policy', buildCsp({ nonce }))
}
return res
}
export const config = {
matcher: ['/api/:path*'],
}
Then your route handler can read the nonce from the request headers if you pass it through cleanly in your architecture. In practice, I often prefer generating the nonce in the handler itself because it’s easier to reason about.
Using a real-world CSP as a reference
Here’s a real CSP header used by headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZjA1N2UwNjgtMDc4NS00ZDA3LWIzMTctYmUwNWE2MzNhM2Iz' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://u.headertest.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
That policy shows what a practical production CSP looks like: strict core directives plus explicit allowances for analytics, consent tooling, API calls, and WebSocket connections.
If you want to inspect how your headers actually look from the outside, headertest.com is useful for checking the final response instead of trusting what you think Next.js is sending.
Common mistakes
1. Setting CSP on JSON and thinking you solved XSS
You didn’t. CSP helps when the browser parses and executes active content. For JSON APIs, focus on content type, output encoding in consumers, and auth boundaries.
2. Forgetting object-src 'none'
This should almost always be there.
3. Missing frame-ancestors
If you don’t want embedding, set:
frame-ancestors 'none'
That’s better than relying on old X-Frame-Options alone.
4. Using unsafe-inline in script-src
Don’t do it unless you’re boxed in by legacy code. Use nonces. style-src 'unsafe-inline' is still common because CSS-in-JS and framework quirks can make it annoying, but for scripts I’m much less forgiving.
5. Allowing broad origins forever
A CSP full of https: and wildcard hosts is barely a policy. Start narrow and add only what breaks for a good reason.
A practical pattern I recommend
For most Next.js apps:
- Pages and HTML responses: full CSP
- API JSON routes: no CSP, but strong type and isolation headers
- Shared helper for security headers
- Report-only mode while tuning policy
Here’s a simple shared helper for API responses:
// lib/api-security.ts
export function applyApiSecurityHeaders(headers: Headers | MapLikeHeaders) {
headers.set('X-Content-Type-Options', 'nosniff')
headers.set('Referrer-Policy', 'no-referrer')
headers.set('Cross-Origin-Resource-Policy', 'same-origin')
headers.set('Cache-Control', 'no-store')
}
type MapLikeHeaders = {
set(name: string, value: string): void
}
And in a route handler:
// app/api/data/route.ts
import { NextResponse } from 'next/server'
import { applyApiSecurityHeaders } from '@/lib/api-security'
export async function GET() {
const headers = new Headers()
applyApiSecurityHeaders(headers)
return new NextResponse(JSON.stringify({ ok: true }), {
status: 200,
headers: {
...Object.fromEntries(headers.entries()),
'Content-Type': 'application/json; charset=utf-8',
},
})
}
That separation keeps your security model honest. HTML gets CSP. Data gets data-oriented protections.
That’s the real trick with CSP for Next.js API routes: don’t force it everywhere just because you can. Use it where the browser will enforce it, and use the right headers everywhere else.