CORS in Next.js is handled by the server: a Route Handler sets cross-origin response headers, and the browser uses them to decide whether JavaScript on another origin may read the response. Without those headers, the browser's same-origin policy blocks the script from reading the response.
The starting point is a single header naming the site that may read the response.
// app/api/products/route.ts
import { getProducts } from '@/lib/products'
export async function GET() {
const products = await getProducts()
return Response.json(products, {
headers: { 'Access-Control-Allow-Origin': 'https://shop.example.com' },
})
}A fetch call from https://shop.example.com now receives the JSON. From any other origin the request still reaches the server, but the browser blocks the script from reading the body and logs a CORS error in the console.
That last point is the one to internalize. The server always runs the handler, so CORS never keeps anyone out.
When the browser sends a preflight first
Simple requests go straight through. Anything else gets an extra OPTIONS request first, and the real request is only sent if that preflight is approved.
A request stops being simple when it uses a method beyond GET, HEAD, or POST, when it sets a custom header such as an authorization header, or when it posts a JSON content type. That covers most real API calls, which is why preflight failures are the usual cause of a broken integration.
Next.js implements OPTIONS automatically when you do not, but that generated response only advertises which methods exist through an Allow header. It carries no cross-origin headers, so you need your own export.
// app/api/products/route.ts
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://shop.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}The 204 status means there is no body, which is a suitable preflight response. A 200 response can also work. In the browser network panel you will see the OPTIONS request succeed, immediately followed by the real request.
The response headers each answer a specific question the browser asked.
| Response header | What it answers |
|---|---|
| Access-Control-Allow-Origin | Which origin may read the response |
| Access-Control-Allow-Methods | Which methods the real request may use |
| Access-Control-Allow-Headers | Which request headers the client may send |
| Access-Control-Allow-Credentials | Whether cookies and auth headers may be sent |
| Access-Control-Max-Age | How long the browser may cache this preflight result |
Adding a max age is worth it once the endpoint is stable, because it stops the browser from repeating the preflight before every call.
Allowing several origins
The allow origin header holds one value, not a list. To support more than one site, check the incoming origin against an allowlist and echo back the match.
// lib/cors.ts
const allowed = ['https://shop.example.com', 'https://admin.example.com']
export function corsHeaders(origin: string | null): Record<string, string> {
const headers = { Vary: 'Origin' }
if (!origin || !allowed.includes(origin)) return headers
return { ...headers, 'Access-Control-Allow-Origin': origin }
}An unknown origin gets no allow-origin header, so the browser blocks the read. The Vary header remains on every response because the result depends on the incoming Origin even when that origin is rejected.
The Vary header tells caches that the response differs per origin. Without it, a shared cache can hand one site a response that names a different site, and the request fails for reasons that look random.
// app/api/products/route.ts
import { corsHeaders } from '@/lib/cors'
import { getProducts } from '@/lib/products'
export async function GET(request: Request) {
const products = await getProducts()
return Response.json(products, {
headers: corsHeaders(request.headers.get('origin')),
})
}Requests from either allowed site get the JSON, and requests from anywhere else get the same JSON on the wire but a blocked read in the browser. The same helper should feed the OPTIONS response so the preflight and the real request agree.
Applying the rules to many routes
Repeating headers in every file gets fragile once you have more than a couple of endpoints. Proxy runs before your routes and can attach the headers in one place.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { corsHeaders } from '@/lib/cors'
export const config = { matcher: '/api/:path*' }
export function proxy(request: NextRequest) {
const response = NextResponse.next()
const headers = corsHeaders(request.headers.get('origin'))
for (const [k, v] of Object.entries(headers)) response.headers.set(k, v)
return response
}The matcher limits this to API paths so page requests and static assets are untouched, which matters because proxy runs on every request without one. That behavior is covered in the matcher config, and the file itself in proxy.ts and what replaced middleware.
A third option is the headers function in next.config.ts, which attaches fixed header values to a path pattern. It cannot inspect the request, so it suits a single known origin rather than an allowlist.
| Scope | Best when | Trade-off |
|---|---|---|
| Route file | One or two endpoints need cross-origin access | Duplicated across files |
| Proxy | Many routes share rules, or the origin is chosen per request | Runs before every matched request |
| Config headers | A fixed value applies to a whole path prefix | Cannot read the incoming origin |
Preflight handling still belongs in the route file when a specific endpoint accepts unusual methods or headers, even if the general rules live elsewhere.
Credentialed requests
If the browser sends cookies or an authorization header with credentials enabled, two extra rules apply. The allow origin value must be an exact origin rather than a wildcard, and the response must include an allow credentials header set to true.
Getting one of the two wrong produces a browser error rather than a silent failure, and the message names the header that is missing.
Common mistakes
- Adding cross-origin headers to GET but leaving the preflight unanswered, so the real request is never sent.
- Adding the headers only to success responses, so browser code cannot read useful error details.
- Using a wildcard origin on an endpoint that reads a session cookie.
- Reflecting whatever origin arrives without checking a list, which allows every site on the internet.
- Treating CORS as security. Server-to-server calls and command line clients ignore it entirely, so keep the auth checks described in authentication architecture and options.
The short version
Set the allow origin header on the response, export an OPTIONS handler for preflighted requests, and reflect origins from an allowlist with a Vary header. Put the rules in the route file for one endpoint, in proxy for many, and keep authentication separate because CORS only constrains browsers.
Rune AI
Key Insights
- CORS headers are set on the response by the server, not by the browser.
- Non-simple requests trigger a preflight, which needs an OPTIONS export.
- Next.js auto-generates OPTIONS only with an Allow header, which is not enough for CORS.
- Reflect an allowed origin from a list and add Vary when the value varies.
- Wildcards cannot be combined with credentialed requests.
- CORS restricts browsers, so it is not a substitute for authentication.
Frequently Asked Questions
Why does my request still fail after I added the allow origin header?
Can I use a wildcard origin with credentials?
Does CORS protect my API?
Should CORS headers live in the route, in proxy, or in next.config?
Conclusion
CORS is opt-in on the server: a Route Handler adds the allow origin header to its response, and an OPTIONS export answers the preflight for anything beyond a simple request. Reflect origins from an allowlist instead of using a wildcard, add a Vary header when the value changes per request, and remember that CORS is a browser rule rather than access control.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.