Problem
Fetch requests from the frontend dev server to a backend API on a different port fail with a CORS error:
Access to fetch at 'http://localhost:4000/api/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.
The fetch call that triggers the error:
// app/dashboard/page.tsx
const res = await fetch("http://localhost:4000/api/users", {
headers: { "Content-Type": "application/json" },
});
// TypeError: Failed to fetch (CORS blocked)
Solution
Option 1: Proxy through Next.js rewrites (recommended)
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
rewrites: async () => [
{
source: "/api/:path*",
destination: "http://localhost:4000/api/:path*",
},
],
};
export default nextConfig;
Then update the fetch call to use a relative URL:
// app/dashboard/page.tsx
const res = await fetch("/api/users", {
headers: { "Content-Type": "application/json" },
});
Option 2: Add CORS headers on the API server
// server.ts (Express)
import cors from "cors";
import express from "express";
const app = express();
app.use(
cors({
origin: "http://localhost:3000",
methods: ["GET", "POST", "PUT", "DELETE"],
})
);
Why It Works
The browser's Same-Origin Policy treats different ports as different origins, so http://localhost:3000 and http://localhost:4000 are cross-origin. Next.js rewrites proxy the request through the frontend's own server, so the browser sees a same-origin request to /api/users and never triggers CORS checks. The actual cross-origin request happens server-to-server where CORS does not apply. With Option 2, the backend explicitly sends Access-Control-Allow-Origin headers permitting the frontend origin, so the browser allows the response through.
Context
- Applies to any local development setup with separate frontend and backend servers on different ports
- Vite offers a similar
server.proxyoption invite.config.tsfor the same purpose - Preflight
OPTIONSrequests are sent by the browser for non-simple requests (custom headers, PUT/DELETE methods) -- the API server must respond to these as well for Option 2 - In production, CORS is typically not an issue if the frontend and API share the same domain or are behind a reverse proxy
- Do not use
origin: "*"withcredentials: true-- browsers reject this combination for security reasons