Next.js Clickjacking Protection
Setting X-Frame-Options and CSP frame-ancestors in Next.js.
Quick config
Add a headers() function to next.config.mjs (.js works too, and .ts on Next.js 15+). It returns an array of path-matched header rules.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'none'",
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
],
},
];
},
};
export default nextConfig;CSP frame-ancestors is the modern control and is enough on its own in current browsers. X-Frame-Options is a fallback for old ones; the Next.js docs say it "has been superseded by CSP's frame-ancestors option."
Keep the two in agreement. When a response carries a frame-ancestors directive, browsers ignore X-Frame-Options entirely, so a loose frame-ancestors cancels DENY.
If you embed your own pages in iframes, replace 'none' with 'self' and DENY with SAMEORIGIN.
Source path patterns
The source field controls which paths a rule applies to. Two patterns match every path:
/:path*: a named parameter with the*(zero or more) modifier, so it matches/and everything below it./(.*): a regex wildcard that also matches all paths.
Prefer /:path*. With Pages Router i18n enabled, Next.js turns /(.*) into /(en|fr|de)/(.*), which misses / and bare locale roots like /fr. /:path* does not have that gap.
Headers are checked before the filesystem, so when a Next.js server handles the request (next start, Vercel), your rules also cover files in public/. Static exports and some adapters bypass this; see the gotchas below.
Conditional headers with has and missing
A rule can include has and missing conditions. It applies only when every has item matches and no missing item does. Conditions can check request headers, cookies, the host, or query parameters.
// next.config.mjs -- deny framing everywhere except the
// dedicated embed host, which only a partner may frame
async headers() {
return [
{
source: '/:path*',
missing: [{ type: 'host', value: 'embed.example.com' }],
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
{
source: '/:path*',
has: [{ type: 'host', value: 'embed.example.com' }],
headers: [
{
key: 'Content-Security-Policy',
value: 'frame-ancestors https://partner.example',
},
],
},
];
}Choose conditions the framing page cannot control. The attacker picks the iframe URL, so a query condition is no gate at all. Cookies fail the other way: cross-site iframes usually do not receive your cookies (SameSite defaults, third-party cookie blocking), so a cookie exemption rarely reaches real embeds. A dedicated host is reliable. For decisions based on auth or other server state, use proxy (below).
Proxy / middleware (dynamic header logic)
When framing rules depend on runtime state (auth status, a database flag, anything a path pattern cannot express), set the headers in code. Next.js 16 renamed middleware.ts to proxy.ts and the exported function to proxy; middleware.ts still works there but is deprecated. On Next.js 15 and earlier, use middleware.ts. Either file goes in the project root, or in src/ if your app lives there.
// proxy.ts (Next.js 16+). On Next.js 15 and earlier, name the
// file middleware.ts and the function middleware.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const response = NextResponse.next();
const isEmbeddable = request.nextUrl.pathname.startsWith('/embed');
if (isEmbeddable) {
response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
} else {
response.headers.set('Content-Security-Policy', "frame-ancestors 'none'");
response.headers.set('X-Frame-Options', 'DENY');
}
return response;
}
export const config = {
// Skip build assets and image optimization; framing headers
// only matter on documents.
matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
};Proxy runs on the Node.js runtime; middleware defaulted to the Edge Runtime before Next.js 16. Setting headers works the same in both.
Priority. next.config headers are evaluated first, then proxy. If both set the same header, the proxy value wins on a Next.js server. Give each header one owner instead of splitting it across both layers.
Vercel deployment (vercel.json)
For non-Next.js projects on Vercel, or to keep headers outside the framework, use the headers array in vercel.json. Vercel applies these at its CDN.
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "frame-ancestors 'none'"
},
{
"key": "X-Frame-Options",
"value": "DENY"
}
]
}
]
}source uses the same path-to-regexp syntax as next.config, and has/missing work here too. To write the same rules in TypeScript, use vercel.ts with the @vercel/config package; a project can have only one of these config files. For a Next.js app, set the headers in one place: next.config or Vercel config, not both.
Gotchas
Static and dynamic routes
On a Next.js server, headers() covers static pages, server-rendered pages, and API routes alike. Coverage still breaks in practice: a proxy matcher that skips /api, or a host that serves prerendered HTML without running your config. Test a page and an /api/* endpoint; a header on / does not guarantee one on /api/health.
Static export (output: 'export')
A static export has no Next.js server, so headers() and proxy/middleware never run; the Next.js docs list both as unsupported. Whatever serves the out/ folder must send the headers: a _headers file on Cloudflare Pages or Netlify, add_header in NGINX, and so on.
Cloudflare Pages with next-on-pages
Do not trust headers() alone on Cloudflare Pages with @cloudflare/next-on-pages (now deprecated and archived). This site shipped there without framing headers even though next.config.mjs set them. One cause: when the next-on-pages router reaches the route Next.js generates for middleware, it resets the headers collected so far, including the ones from headers().
This site now uses both fixes. Middleware sets the framing headers (next-on-pages applies middleware headers after that reset), which covers 404 pages and API routes. A /* rule in public/_headers covers responses Cloudflare serves from static assets:
# public/_headers
/*
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'After deploying, curl a static page, a URL that 404s, and an API route.
Cache-Control on static assets
Next.js sends Cache-Control: public, max-age=31536000, immutable on hashed build assets such as /_next/static, and you cannot override it. That has no effect on frame-ancestors or X-Frame-Options; they are separate headers.
basePath
If next.config sets basePath, every header source is prefixed with it automatically. To match paths without the prefix, set basePath: false on the rule.
Internationalized routing
With Pages Router i18n enabled, source paths are prefixed with the configured locales automatically. Set locale: false on a rule to match the path exactly as written, locale prefix included.
Verify it is working
After deploying, check that the headers are present on both a page and an API route:
curl -I https://your-app.vercel.app/
curl -I https://your-app.vercel.app/api/healthLook for Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY in the output.
Then run it through ClickJack Test to confirm.