CORS and Cross-Origin Requests

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.

An allowlist with a real preflight (app/api/public/route.ts)TypeScript
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:

Output of 55
$ 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: 86400

The 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*.