Skip to content

Fix: Next.js CORS Error on API Routes

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix CORS errors in Next.js API routes — adding Access-Control headers, handling preflight OPTIONS requests, configuring next.config.js headers, and avoiding common proxy mistakes.

The Error

A browser fetch or axios request to a Next.js API route fails with a CORS error:

Access to fetch at 'https://api.example.com/api/users' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.

Or the preflight OPTIONS request fails:

Access to XMLHttpRequest at 'https://api.example.com/api/data' from origin
'https://app.example.com' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: It does not have HTTP ok status.

In the Network tab, the failed request shows:

  • Method: OPTIONS (preflight), Status: 405 Method Not Allowed
  • Or Method: GET/POST, Status: 200 but the response is blocked because of missing CORS headers

Why This Happens

CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When your frontend (on app.example.com) makes a request to an API (on api.example.com), the browser first checks whether the server allows cross-origin requests by looking for Access-Control-Allow-Origin in the response headers. The check is purely client-side: the network round trip still happens, but the browser refuses to expose the response to your JavaScript unless the server opts in with the right headers.

There are actually two distinct flows. “Simple” requests (GET, HEAD, and POST with form-style content types and no custom headers) trigger only a single round trip — the browser sends the request and inspects the response headers. Anything else — PUT, DELETE, PATCH, JSON bodies, custom Authorization headers — first sends a preflight OPTIONS request asking permission. If the server does not answer the preflight with the right Access-Control-Allow-* headers, the real request never happens. Many Next.js CORS bugs are actually preflight bugs hidden by the fact that the failing request in DevTools is the OPTIONS and not the underlying POST.

Next.js API routes don’t set CORS headers by default. Common causes:

  • Missing CORS headers — the API route returns a response without Access-Control-Allow-Origin.
  • Preflight not handled — for requests with custom headers or non-simple methods (PUT, DELETE, PATCH), browsers send an OPTIONS preflight request first. If your route doesn’t handle OPTIONS, Next.js returns 405.
  • Wildcard origin with credentialsAccess-Control-Allow-Origin: * doesn’t work when credentials: 'include' is used. You must specify the exact origin.
  • Frontend and API on different domains — during development, the frontend runs on localhost:3000 and calls an API on localhost:4000 — different ports = different origins.
  • Missing headers in next.config.js — if you configure CORS in next.config.js headers but the pattern doesn’t match your API routes, the headers won’t be added.

Version History That Changes the Failure Mode

Next.js has changed its API routing model twice in recent years, and the version you target determines which fix below is the right one. Through Next 12, the only way to define an HTTP endpoint was the Pages Router: a file at pages/api/foo.ts exporting a default handler with the (req, res) signature. CORS in this model is plain Node.js — call res.setHeader('Access-Control-Allow-Origin', '...') and return 200 from OPTIONS manually.

Next 13, released in October 2022, introduced the App Router with app/api/foo/route.ts exporting named functions per HTTP verb (GET, POST, OPTIONS). These use Web standard Request/Response objects instead of the Node.js-style ones. App Router routes run on the Edge runtime by default if you set export const runtime = 'edge', in which case the Node-only APIs (Buffer, fs, some setHeader nuances) aren’t available. Edge routes must construct responses with the Headers API or pass a headers option to NextResponse.json(). App Router became stable in 13.4 (May 2023). Both routers can coexist in the same project, so a bug report that “CORS works in one route but not another” often comes down to mixing the two patterns.

Next 14 (October 2023) brought Server Actions to stable. Server Actions are not API routes — they’re invoked via an internal POST to the same origin and never trigger CORS — so if you migrated an endpoint to a Server Action, the CORS error simply disappears because the request is no longer cross-origin. Next 15 (October 2024) changed default caching behavior for GET route handlers (no longer cached by default) and tightened how cookies() and headers() are awaited, but the CORS API surface stayed the same. Middleware, available since Next 12.2, is still the only place to apply CORS globally across both routers without per-route boilerplate.

Fix 1: Add CORS Headers Directly in the API Route

The most straightforward fix: set the headers in each route handler. For the App Router:

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};

// Handle preflight
export async function OPTIONS() {
  return NextResponse.json({}, { headers: corsHeaders });
}

export async function GET(request: NextRequest) {
  const users = await getUsers();
  return NextResponse.json(users, { headers: corsHeaders });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await createUser(body);
  return NextResponse.json(user, { status: 201, headers: corsHeaders });
}

For development, allow any origin:

const allowedOrigins = ['https://app.example.com', 'http://localhost:3000'];

function getCorsHeaders(request: NextRequest) {
  const origin = request.headers.get('origin') ?? '';
  const isAllowed = allowedOrigins.includes(origin);
  return {
    'Access-Control-Allow-Origin': isAllowed ? origin : allowedOrigins[0],
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  };
}

For the Pages Router (pages/api/):

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  // Set CORS headers
  res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

  // Handle preflight
  if (req.method === 'OPTIONS') {
    res.status(200).end();
    return;
  }

  if (req.method === 'GET') {
    res.json({ users: [] });
  } else {
    res.status(405).json({ error: 'Method not allowed' });
  }
}

Fix 2: Create a CORS Middleware Helper

To avoid repeating CORS headers in every route, create a reusable helper:

For the App Router:

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

type CorsOptions = {
  origin?: string | string[];
  methods?: string[];
  headers?: string[];
};

export function withCors(
  handler: (req: NextRequest) => Promise<NextResponse>,
  options: CorsOptions = {}
) {
  const {
    origin = '*',
    methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    headers = ['Content-Type', 'Authorization'],
  } = options;

  return async (request: NextRequest): Promise<NextResponse> => {
    const requestOrigin = request.headers.get('origin');

    const allowOrigin = Array.isArray(origin)
      ? (origin.includes(requestOrigin ?? '') ? requestOrigin : origin[0]) ?? '*'
      : origin;

    const corsHeaders = {
      'Access-Control-Allow-Origin': allowOrigin,
      'Access-Control-Allow-Methods': methods.join(', '),
      'Access-Control-Allow-Headers': headers.join(', '),
    };

    if (request.method === 'OPTIONS') {
      return NextResponse.json({}, { headers: corsHeaders });
    }

    const response = await handler(request);

    Object.entries(corsHeaders).forEach(([key, value]) => {
      response.headers.set(key, value);
    });

    return response;
  };
}

Use it in your routes:

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

async function getUsers(request: NextRequest) {
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

export const GET = withCors(getUsers, {
  origin: ['https://app.example.com', 'http://localhost:3000'],
});

Fix 3: Configure CORS in next.config.js

For a centralized approach that applies to all API routes without modifying each one, use headers() in your Next.js config:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        // Apply to all API routes
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

Limitation: The headers() config doesn’t handle preflight OPTIONS requests — Next.js returns 405 for OPTIONS by default. You still need to add OPTIONS handlers in each route, or use middleware.

Fix 4: Use Next.js Middleware for Global CORS

Middleware runs before every request, making it the cleanest place for global CORS handling:

// middleware.ts (in project root, not src/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const allowedOrigins = [
  'https://app.example.com',
  'http://localhost:3000',
];

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

  // Handle preflight OPTIONS
  if (request.method === 'OPTIONS') {
    const response = new NextResponse(null, { status: 200 });
    if (isAllowed) {
      response.headers.set('Access-Control-Allow-Origin', origin);
    }
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    response.headers.set('Access-Control-Max-Age', '86400');
    return response;
  }

  const response = NextResponse.next();

  if (isAllowed) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }

  return response;
}

export const config = {
  matcher: '/api/:path*',   // Only apply to API routes
};

This handles both regular requests and OPTIONS preflight in one place.

Fix 5: Fix CORS with Credentials

If you send cookies or Authorization headers with credentials: 'include', the wildcard origin * won’t work — the browser requires a specific origin:

// Client-side fetch
fetch('https://api.example.com/api/profile', {
  credentials: 'include',   // Sends cookies
  headers: { Authorization: `Bearer ${token}` },
});

Server must respond with the specific origin and credentials flag:

// app/api/profile/route.ts
export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin') ?? '';

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': origin,           // Specific origin, not '*'
      'Access-Control-Allow-Credentials': 'true',      // Required for credentials
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}

Warning: Setting Access-Control-Allow-Origin to the request’s origin header without validation allows any origin. Always validate against an allowlist before reflecting the origin back.

Fix 6: Use a Proxy for Same-Origin Requests

The cleanest solution for a Next.js frontend calling its own backend is to route through Next.js itself — no CORS at all because both frontend and API are on the same origin:

// next.config.js — proxy /api/external to another server
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/external/:path*',
        destination: 'https://api.external-service.com/:path*',
      },
    ];
  },
};

Your frontend calls /api/external/users (same origin), Next.js proxies it to the external API server-side — no browser CORS check.

Still Not Working?

Confirm the CORS headers are actually in the response — open DevTools → Network → click the failed request → check Response Headers. If Access-Control-Allow-Origin is missing, the headers aren’t being set by your code.

Check for multiple CORS headers. Some proxy configurations (nginx, Cloudflare) add CORS headers, and if your Next.js code also adds them, you get duplicate headers like:

Access-Control-Allow-Origin: https://app.example.com, *

Multiple values for Access-Control-Allow-Origin are invalid. Remove CORS headers from one layer.

Check the OPTIONS preflight response specifically. In DevTools Network tab, filter by “OPTIONS” method. If it returns 405, your route isn’t handling OPTIONS. If it returns 200 but the main request still fails, check that the Access-Control-Allow-Headers includes every custom header your request sends.

Verify the origin header is present. Browsers only send the Origin header for cross-origin requests. If you’re testing with curl or Postman, add -H "Origin: http://localhost:3000" to trigger CORS header logic.

Check whether middleware is being skipped for your route. Next.js middleware has a matcher config and a list of paths it ignores by default (_next/static, _next/image, favicon.ico). If your matcher is too narrow or accidentally excludes the API path, the CORS logic never runs. Log request.nextUrl.pathname at the top of the middleware function and confirm it fires for the failing endpoint.

Look at Vary: Origin in the response. When you reflect the request’s Origin back into Access-Control-Allow-Origin, you should also send Vary: Origin so that CDN caching and the browser’s HTTP cache do not serve a response cached for app.example.com to a request from staging.example.com. Without it, two clients on different origins can end up sharing the same cached response and both fail CORS.

Check that Edge runtime is not stripping headers. When runtime = 'edge' is set, Next.js wraps your response in its own pipeline, and certain header names with unusual casing or duplicated entries get normalized. Construct headers with the Headers class directly rather than passing a plain object, and avoid setting the same header twice. Edge responses run through Cloudflare’s or Vercel’s edge layer in production, which can also add their own CORS-related headers.

For related Next.js issues, see Fix: Next.js API Route Not Working, Fix: Next.js Environment Variables Not Working, Fix: Next.js Middleware Not Running, and Fix: CORS Preflight Request Blocked.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles