Integration · Next.js

CORS in Next.js 16: Route Handlers and proxy.ts

Next.js doesn't send CORS headers on its own — a Route Handler behaves like any other server, and a cross-origin fetch against it fails exactly the way it would against a bare Node server with no CORS middleware. We built and ran small Next.js 16.3 apps to check exactly what happens by default, what each fix costs you, and one change in Next.js 16 — middleware.ts becoming proxy.ts — that breaks CORS setups copied from older tutorials.

· ~7 min read

What happens if you add nothing

A Route Handler under app/ that only exports GET will happily set Access-Control-Allow-Origin on its own response — nothing stops you from adding headers to a Response. The gap is the preflight. We sent an OPTIONS request to a route that exports no OPTIONS handler at all, on a plain Next.js 16.3 app:

curl -i -X OPTIONS http://localhost:3000/api/no-options \
  -H "Origin: https://example.com" \
  -H "Access-Control-Request-Method: GET"

It came back 204 No Content with an Allow: GET, HEAD, OPTIONS header — not the 405 that older posts about this describe. Next.js auto-generates that OPTIONS response for any route it knows the methods for. But it carries no Access-Control-Allow-Origin, Access-Control-Allow-Methods, or Access-Control-Allow-Headers. A 204 looks like success in a terminal, but the browser reads the same response and still blocks the real request, because the preflight response didn't grant it anything. If your API only ever receives simple GET requests with no custom headers, no preflight fires and this never bites you. The moment a client sends Authorization, a JSON body with PUT/PATCH/DELETE, or any credentialed request, it does.

Fix it per route: export your own OPTIONS

The reliable fix is to export OPTIONS yourself, alongside whatever methods the route actually serves, and set the headers on both:

// app/api/widgets/route.ts
const ALLOWED_ORIGIN = 'https://app.example.com';

export async function GET(request: Request) {
  return Response.json({ widgets: [] }, {
    headers: {
      'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
      'Vary': 'Origin',
    },
  });
}

export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': '600',
      'Vary': 'Origin',
    },
  });
}

We re-ran the same curl command against this version: the 204 now carries all four Access-Control-* headers, and the browser treats the preflight as a grant. This is the right amount of code for one route, or a handful of routes with different rules. It stops scaling once you have a dozen routes that all need the same policy — that's what the next two options are for. Note that App Router responses also set their own Vary header (rsc, next-router-state-tree, …) for React Server Components; it's unrelated to CORS and sits alongside your own Vary: Origin, not in place of it.

proxy.ts: one place for every API route — and a breaking rename in Next.js 16

For a shared policy across routes, Next.js has long supported a middleware.ts file that runs before the route. In Next.js 16 that file convention is deprecated: building a fresh 16.3.8 app with a middleware.ts at the root still works, but prints The "middleware" file convention is deprecated. Please use "proxy" instead. at build time, with a codemod (npx @next/codemod@canary middleware-to-proxy .) to migrate automatically. We renamed the file to proxy.ts and reran the build; it only succeeded once we also renamed the exported function from middleware to proxy — the file name alone isn't enough, Next.js build fails with "Proxy is missing expected function export name" if the export still says middleware.

// proxy.ts (Next.js 16+; was middleware.ts)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const ALLOWED_ORIGIN = 'https://app.example.com';

export function proxy(request: NextRequest) {
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '600',
        'Vary': 'Origin',
      },
    });
  }

  const response = NextResponse.next();
  if (request.headers.get('origin') === ALLOWED_ORIGIN) {
    response.headers.set('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
    response.headers.set('Vary', 'Origin');
  }
  return response;
}

export const config = {
  matcher: '/api/:path*',
};

We tested this against three requests: an OPTIONS preflight (got the full set of headers back), a GET with a matching Origin (got Access-Control-Allow-Origin added), and a GET with a different origin (got neither header — the browser blocks it, which is the point). The matcher is what makes this cheaper than repeating the Route Handler pattern on every file: one gate for every path it covers, evaluated once per request instead of copy-pasted per route.

next.config.js headers(): static rules — until output: 'export'

For a header that never varies by request, next.config.js can declare it directly:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
        ],
      },
    ];
  },
};

This only works where Next.js is actually running a server to apply it. We added output: 'export' to a test config that also had headers() set, and the build printed a warning — Specified "headers" will not automatically work with "output: export" — and then failed outright on the API routes themselves, because a static export has no server to run a Route Handler on at all. If you're shipping a fully static export, headers(), proxy.ts, and dynamic Route Handlers are all off the table together, not just the header config.

Two mistakes that show up after it "works"

When none of this is the fix

Everything above controls headers Next.js itself sends. It does nothing for a third-party API you're calling from a client component that doesn't send CORS headers back — proxy.ts runs on your origin, not theirs. For that case, the fix is either a server-side call from a Route Handler (same-origin as far as the browser's concerned) or a CORS proxy in front of the third-party API. For CORS you do control, tune Access-Control-Max-Age once the headers are right, so the browser isn't re-running the preflight on every request.