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 · Updated Oct 13, 2026

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, or a PUT/PATCH/DELETE, it does. (Cookies alone don't trigger a preflight; see credentialed requests.)

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, which is 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: the 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, so 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 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 the API isn't yours

proxy.ts only changes responses from your own Next.js app. If a client component calls a third-party API that sends no CORS headers, move the call into a Route Handler (or a Server Component) and fetch from there: the browser then talks only to your origin. A CORS proxy is the alternative when you're on output: 'export' and have no server to put that Route Handler on.