CORS10 min read

CORS Errors in Production: Next.js Fix Guide 2026

Author:Rutik Vasani

What causes CORS errors in Next.js production? A production Cross-Origin Resource Sharing (CORS) error occurs when a client browser blocks an HTTP response because the server fails to provide valid Access-Control-Allow-Origin headers during preflight checks (OPTIONS) or credential negotiations, most commonly triggered when a frontend (app.domain.com) and backend (api.domain.com) deploy to separate origins without explicit cross-origin permissions.

Few debugging experiences are as maddening as code that executes flawlessly on http://localhost:3000 only to implode the moment it hits Vercel or your production cloud environment. In development, your frontend and API often share the same port or rely on local proxy rewrites. In production, domain segregation, authentication cookies, custom request headers, and microservices immediately trigger the browser's strict security boundary: the Cross-Origin Resource Sharing mechanism.

When CORS fails in production, it is rarely an API crash. The API endpoint often processes the request, mutates the database, and returns HTTP 200, but the user's browser blocks the frontend JavaScript from reading the response. To the user, buttons freeze and data vanishes, resulting in silent frontend failures that drive immediate customer churn.

This guide walks through the exact mechanics of production CORS in Next.js (App Router and Pages Router), identifies the architectural pitfalls that cause cross-origin failures, and provides hardened TypeScript configurations to eliminate them permanently.


Anatomy of the Production CORS Failure

CORS is not an authorization mechanism; it is a browser-enforced security protocol designed to protect users from malicious cross-origin requests using saved credentials. Understanding why your request failed requires breaking down how modern browsers evaluate cross-origin traffic.

+------------------+         OPTIONS (Preflight)          +------------------+
|                  | -----------------------------------> |                  |
|  User's Browser  |                                      |   Next.js API    |
| (app.domain.com) | <----------------------------------- | (api.domain.com) |
|                  |   204 No Content + CORS Headers      |                  |
|                  |                                      +------------------+
|                  |         Actual Request (POST)                 |
|                  | --------------------------------------------->|
|                  | <---------------------------------------------|
|                  |   200 OK + Access-Control-Allow-Origin        |
+------------------+

1. The Preflight OPTIONS Handshake

Browsers automatically send an OPTIONS preflight request before any "non-simple" HTTP request. A request requires preflight if it:

  • Uses HTTP methods other than GET, HEAD, or POST.
  • Sends custom headers such as Authorization, x-api-key, or traceparent.
  • Uses a Content-Type header other than application/x-www-form-urlencoded, multipart/form-data, or text/plain (almost every modern API request uses application/json).

If your Next.js route handler or API gateway does not explicitly handle the OPTIONS method and return an HTTP 200 or 204 with the appropriate headers, the browser immediately aborts the pipeline. The actual POST or GET request is never dispatched.

2. The Wildcard with Credentials Trap

The most pervasive production CORS bug happens when developers attempt to fix header errors by returning:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The Fetch specification explicitly forbids this combination. If your frontend uses credentials: 'include' to send session cookies or HTTP-only authorization tokens across subdomains, the browser rejects wildcard origins outright. You must reflect the exact requesting origin back in the Access-Control-Allow-Origin header after verifying it against an internal allowlist.

3. Subdomain Isolation

When your client application lives at https://app.yourdomain.com and your Next.js API runs at https://api.yourdomain.com, they are different origins. Even though they share the root domain yourdomain.com, the port, protocol, or host differences trigger full cross-origin evaluation. If your production environment variables lack the exact client origin (or omit the https:// protocol), all cross-origin fetches fail.


Next.js CORS Architecture: Rewrites vs Middleware vs Route Handlers

When architecting cross-origin communication in Next.js, you have three primary approaches:

Approach Implementation Layer Preflight Handshake Required? Best For
Next.js Rewrites Reverse Proxy (next.config.ts) No (Same-origin illusion) Monolithic frontends calling internal backend services
Edge Middleware Edge Runtime (middleware.ts) Yes (Handled globally) Multi-tenant SaaS, cross-subdomain architectures
Route Handler Wrapper API Route (route.ts) Yes (Handled per-route) Granular microservices with varying security policies

Approach 1: Rewrites (Eliminate CORS Completely)

If your frontend and API are hosted under the same Next.js deployment or you can proxy traffic through Next.js, rewrites bypass CORS entirely by creating a same-origin illusion:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/v1/:path*',
        destination: `${process.env.BACKEND_API_INTERNAL_URL}/api/v1/:path*`,
      },
    ];
  },
};

export default nextConfig;

Because the browser fetches /api/v1/users on app.yourdomain.com, it treats the request as same-origin. The Next.js server handles the backend communication server-to-server, where CORS rules do not apply.


Production-Ready Implementation: Next.js Edge Middleware

When your API is consumed across multiple subdomains, mobile webviews, or third-party partner portals, Edge Middleware provides centralized, high-performance CORS handling.

Here is a production-grade TypeScript middleware implementation handling origin allowlists, dynamic regex matching for preview environments (like Vercel preview URLs), and preflight response caching:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// 1. Define allowed origins and dynamic patterns
const ALLOWED_ORIGINS = new Set([
  'https://yourdomain.com',
  'https://app.yourdomain.com',
  'https://admin.yourdomain.com',
]);

// Allow Vercel preview deployments safely using strict regex
const PREVIEW_ORIGIN_REGEX = /^https:\/\/app-git-[a-zA-Z0-9_-]+-yourteam\.vercel\.app$/;

function isOriginAllowed(origin: string): boolean {
  if (ALLOWED_ORIGINS.has(origin)) return true;
  if (PREVIEW_ORIGIN_REGEX.test(origin)) return true;
  return false;
}

export function middleware(request: NextRequest) {
  const origin = request.headers.get('origin') ?? '';
  const isAllowed = isOriginAllowed(origin);

  // Default headers dictionary
  const corsHeaders: Record<string, string> = {
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, traceparent',
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours to reduce latency
  };

  if (isAllowed) {
    corsHeaders['Access-Control-Allow-Origin'] = origin;
    corsHeaders['Vary'] = 'Origin';
  }

  // Handle preflight OPTIONS request
  if (request.method === 'OPTIONS') {
    if (!isAllowed) {
      return new NextResponse(null, { status: 403 });
    }
    return new NextResponse(null, {
      status: 204,
      headers: corsHeaders,
    });
  }

  // Handle actual API request
  const response = NextResponse.next();
  Object.entries(corsHeaders).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

  return response;
}

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

Key Production Details in This Implementation:

  1. Access-Control-Max-Age: 86400: Without this header, browsers fire an OPTIONS preflight request before every single API call, doubling network latency. Caching preflights for 24 hours cuts API round-trip times in half.
  2. Vary: Origin: Informs intermediate CDNs and browser caches that the response headers vary based on the request's Origin. This prevents cache poisoning where a response meant for https://app.yourdomain.com is served to an untrusted domain.
  3. Safe Subdomain Regex: Never use loose substring checks like origin.includes('vercel.app'), which opens you to subdomain spoofing attacks from arbitrary Vercel accounts.

Route Handler Level CORS Wrapper

If you prefer isolated handling per route handler without running middleware on every request, use a reusable wrapper function for your Next.js App Router endpoints:

// lib/cors.ts
import { NextResponse } from 'next/server';

interface CorsOptions {
  allowedOrigins: string[];
}

export function withCors(
  handler: (req: Request) => Promise<Response>,
  options: CorsOptions
) {
  return async (req: Request) => {
    const origin = req.headers.get('origin') || '';
    const isAllowed = options.allowedOrigins.includes(origin);

    const headers = new Headers({
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Allow-Credentials': 'true',
      'Access-Control-Max-Age': '86400',
    });

    if (isAllowed) {
      headers.set('Access-Control-Allow-Origin', origin);
      headers.set('Vary', 'Origin');
    }

    if (req.method === 'OPTIONS') {
      return new NextResponse(null, { status: 204, headers });
    }

    const response = await handler(req);
    headers.forEach((value, key) => {
      response.headers.set(key, value);
    });

    return response;
  };
}

You can then apply this wrapper to any app/api/checkout/route.ts:

// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { withCors } from '@/lib/cors';

const allowed = ['https://app.yourdomain.com', 'https://yourdomain.com'];

async function postHandler(req: Request) {
  const body = await req.json();
  // Process checkout logic...
  return NextResponse.json({ success: true, orderId: 'ord_123' });
}

export const POST = withCors(postHandler, { allowedOrigins: allowed });
export const OPTIONS = withCors(async () => new NextResponse(null, { status: 204 }), {
  allowedOrigins: allowed,
});

For more patterns on robust error wrapping, review our Next.js API Routes Error Handling Guide.


Step-by-Step Diagnostic & Troubleshooting Checklist

When CORS errors strike production, work through this checklist systematically instead of blindly tweaking headers:

[ ] Step 1: Inspect the DevTools Network Tab
    - Filter by Fetch/XHR.
    - Check the method: Did the OPTIONS preflight fail, or did the subsequent GET/POST fail?
    - If OPTIONS returned 405 Method Not Allowed, your route handler lacks an export for OPTIONS.
    - If OPTIONS returned 401 Unauthorized, an auth middleware intercepted the preflight before CORS headers were appended.

[ ] Step 2: Validate the Exact Request Origin
    - Look at the `Origin` header in Request Headers.
    - Does it include a trailing slash? (e.g., `https://app.domain.com/` vs `https://app.domain.com`)
    - Does the protocol match? (`http://` vs `https://`)

[ ] Step 3: Reproduce Using cURL
    - Run:
      curl -v -X OPTIONS "https://api.yourdomain.com/api/v1/user" \
        -H "Origin: https://app.yourdomain.com" \
        -H "Access-Control-Request-Method: POST" \
        -H "Access-Control-Request-Headers: Content-Type, Authorization"
    - Verify that HTTP 200/204 is returned and `Access-Control-Allow-Origin` matches.

[ ] Step 4: Check for Intermediate Reverse Proxies
    - Are Cloudflare, AWS CloudFront, or Vercel Edge caching old 502/404 responses that lack CORS headers?
    - Ensure your CDN caches pass through `Vary: Origin`.

[ ] Step 5: Check Credentials Configuration
    - If frontend sends `credentials: 'include'`, ensure `Access-Control-Allow-Credentials: true` is present AND `Access-Control-Allow-Origin` is NOT '*'.

When local tests pass but live environments fail, consult our deep dive on what to do when you can't reproduce a production bug locally.


Autonomous AutoOps: Stop CORS Regressions with Relia

Production CORS failures are notorious because they fail silently in the browser. Backend metrics report healthy green dashboards with 0 server exceptions, while thousands of paying users encounter blank screens or non-responsive checkout buttons.

This is where Relia changes production debugging.

Relia is an autonomous AutoOps engine that monitors live web applications, captures runtime failures and session traces in real time, and isolates the exact root cause sequence—pinpointing the failing service, the misconfigured header file, and the drifted domain dependency. Instead of leaving on-call engineers to spend hours correlating browser console dumps with CloudWatch logs, Relia identifies the root cause and provides the verified code patch to fix it.

"The first user triggers the bug. Relia finds it, understands it, and provides the fix before the second user ever hits it."

Whether it is an unhandled OPTIONS preflight in a newly deployed route handler, a missing cookie header in your Edge Middleware, or an expired CDN cache rule, Relia provides immediate remediation before customers drop off. Connect your repository and runtime telemetry at app.tryrelia.com.


FAQ

How do I fix a CORS error in Next.js production?

Add the requesting origin to your API's Access-Control-Allow-Origin response header, implement an explicit OPTIONS handler that returns HTTP 200 or 204 with valid CORS headers, and ensure Access-Control-Allow-Credentials is set to true if passing session cookies. Redeploy and verify headers in browser DevTools.

Why does my API work in Postman or cURL but fail in the browser?

CORS is enforced strictly by web browsers to protect end users. Postman, cURL, and mobile native apps are not browsers and bypass CORS rules entirely. If a request works in Postman but fails in Chrome, your server logic is functioning, but your server-side CORS response headers are misconfigured.

Is setting mode: 'no-cors' on frontend fetch a valid fix?

No. Setting mode: 'no-cors' downgrades the request to an opaque exchange. It disables custom headers like Authorization and prevents your JavaScript code from accessing the response body or status code, breaking dynamic UI functionality. The fix must always occur on the server.

Can I use Access-Control-Allow-Origin: * with cookies?

No. Modern web browsers strictly reject any cross-origin response that pairs Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. You must validate the incoming request Origin header against an allowlist and reflect that exact origin back in the response.

[ MORE ARTICLES ]

Read Next

View all →