Handling CORS in Next.js Route Handlers

Set cross-origin headers on a Route Handler, answer preflight requests with an OPTIONS export, and choose between per-route, proxy, and config-wide approaches.

8 min read

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.

typescripttypescript
// 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.

typescripttypescript
// 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 headerWhat it answers
Access-Control-Allow-OriginWhich origin may read the response
Access-Control-Allow-MethodsWhich methods the real request may use
Access-Control-Allow-HeadersWhich request headers the client may send
Access-Control-Allow-CredentialsWhether cookies and auth headers may be sent
Access-Control-Max-AgeHow 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

ScopeBest whenTrade-off
Route fileOne or two endpoints need cross-origin accessDuplicated across files
ProxyMany routes share rules, or the origin is chosen per requestRuns before every matched request
Config headersA fixed value applies to a whole path prefixCannot 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why does my request still fail after I added the allow origin header?

The browser probably sent a preflight request first, and your route has no OPTIONS export or the preflight response is missing the allowed method or header. Check the OPTIONS response in the network panel, not the main request.

Can I use a wildcard origin with credentials?

No. When a request carries cookies or an authorization header with credentials mode, the browser rejects a wildcard and requires the exact origin, plus an allow credentials header set to true.

Does CORS protect my API?

No. It only tells browsers which sites may read a response. Any server, script, or command line client can still call the endpoint, so authentication and validation are still required.

Should CORS headers live in the route, in proxy, or in next.config?

Use the route file for one or two endpoints, proxy when several routes share the rules and the allowed origin is computed per request, and the config headers when a fixed value applies to a whole path prefix.

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.