Your own pages call your own handlers from the same origin, so CORS never comes up. It appears the moment something else calls them: a front end on another domain, a partner's dashboard. The browser then sends an Origin header and checks the reply — or, for anything beyond a simple GET or form post, sends an OPTIONS preflight first and refuses to proceed until it is approved.
Because a handler returns a plain Response, CORS is just headers. Resist the 'Access-Control-Allow-Origin': '*' that every tutorial starts with: a wildcard is incompatible with credentials. Echo an allowlisted origin instead, and export OPTIONS to control the preflight.
const allowed = ['https://shop.example.com', 'https://admin.example.com']
function corsHeaders(origin: string | null) {
const h = new Headers({ Vary: 'Origin' })
if (origin && allowed.includes(origin)) {
h.set('Access-Control-Allow-Origin', origin)
h.set('Access-Control-Allow-Credentials', 'true')
}
return h
}
export async function OPTIONS(request: Request) {
const h = corsHeaders(request.headers.get('origin'))
h.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
h.set('Access-Control-Max-Age', '86400')
return new Response(null, { status: 204, headers: h })
}The GET in the same file builds its headers from the same helper. A preflight from an allowed origin comes back approved:
$ curl -s -i -X OPTIONS http://localhost:3000/api/public \
-H "Origin: https://shop.example.com" -H "Access-Control-Request-Method: POST"
HTTP/1.1 204 No Content
vary: Origin
access-control-allow-credentials: true
access-control-allow-headers: Content-Type, Authorization
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-origin: https://shop.example.com
access-control-max-age: 86400The same GET sent with Origin: https://evil.example.com is the instructive one: it still returns 200 OK with the full body, and only vary: Origin comes back with it. CORS is enforced by the browser, not the server, so with no Access-Control-Allow-Origin header the browser discards the response before any script reads it. That is why curl 3,008 "works" while the page fails, and why CORS is not access control: anything that must not leak needs an authentication check inside the handler.
Vary: Origin is the header people forget; without it a shared cache can hand the response built for an allowed origin to everyone. For a whole API surface set these once in next.config.ts under headers(), or in proxy.ts matched to /api/:path*.