Why CORS Breaks Between Next.js and NestJS: Our Field Notes on Preflights, Credentialed Cookies, and Proxying Instead
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Headline: CORS (Cross-Origin Resource Sharing) is a browser rule that decides whether page JavaScript may read a response from another origin; it does not stop the request from reaching the server. For a Next.js frontend calling a NestJS API with cookies, we either return the exact origin with Access-Control-Allow-Credentials: true and a 2xx preflight, or proxy /api through a Next.js rewrite so the browser never makes a cross-origin call.
Every project where our team splits a Next.js frontend from a NestJS API walks into the same three CORS errors in the same order. First the preflight fails. Then the wildcard origin gets rejected as soon as cookies are involved. Then every call returns 200 and the user is still logged out, because the session cookie never travels. These are our field notes on what each failure means and the setup we standardized on.
Key takeaways
• CORS is enforced by the browser, not the server: it controls whether page JavaScript can read a cross-origin response, and it does not stop the request from reaching the API.
• A JSON Content-Type, an Authorization header, or a PUT, PATCH, or DELETE method makes a request non-simple, so the browser first sends an OPTIONS preflight — without cookies — and needs a 2xx response with matching allow headers.
• Credentialed requests (credentials: 'include') require Access-Control-Allow-Credentials: true plus the exact requesting origin; Access-Control-Allow-Origin: * is rejected.
• Cookies cross origins only when the request opts in and SameSite allows it; across different sites that means SameSite=None; Secure, which Safari's default third-party cookie blocking still defeats.
• Proxying /api/* through a Next.js rewrite makes the API same-origin: no preflights and first-party cookies, at the cost of one extra network hop.
What does CORS actually protect?
CORS protects the user's data inside the browser, not the server. An origin is the combination of scheme, host, and port, so app.example.com and api.example.com are different origins. The same-origin policy stops a page on one origin from reading responses from another, and CORS response headers are how a server opts specific origins back in.
• Only browsers enforce CORS. curl, Postman, a Next.js Server Component, and a NestJS service calling another service never send preflights or check Access-Control-* headers.
• A blocked request may still have run. For a simple cross-origin request, the browser sends it, the server executes it, and only the response is hidden from JavaScript.
• CORS is not CSRF protection. A forged form POST from another site still reaches the API with whatever cookies the browser attaches; SameSite cookies, CSRF tokens, or an Origin header check are what stop it.
Same-origin and same-site are different tests, and cookies follow the same-site test. A site is the scheme plus the registrable domain (eTLD+1, such as example.com), and ports do not count:
• app.example.com to api.example.com: cross-origin because the host differs, same-site because both are on example.com.
• localhost:3000 to localhost:4000: cross-origin because the port differs, same-site because ports do not count.
• shop.vercel.app to shop-api.vercel.app: cross-origin and cross-site, because vercel.app is on the Public Suffix List.
• example.com to example.com: cross-origin and cross-site, because Chrome uses schemeful same-site.
Why does a fetch call trigger a preflight OPTIONS request?
The browser sends an OPTIONS preflight before any cross-origin request that is not simple. A simple request uses GET, HEAD, or POST, sets only CORS-safelisted headers, and sends a Content-Type of text/plain, multipart/form-data, or application/x-www-form-urlencoded. A JSON body, an Authorization header, a custom header such as Idempotency-Key, or a PUT, PATCH, or DELETE method each force a preflight, so almost every real API call gets one. A typical frontend call — fetch() with method POST, credentials: 'include', and Content-Type: application/json — is preflighted every time.
The preflight announces the method and headers in Access-Control-Request-Method and Access-Control-Request-Headers. The server must answer with a 2xx status plus Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers values that cover them. Preflight requests never carry cookies or an Authorization header. Our first failure came from exactly that rule: an auth middleware registered with app.use() above app.enableCors() answered every preflight with 401, and the browser reported it as a CORS error.
Access-Control-Max-Age sets how long the browser caches a preflight result. Without the header, the Fetch spec default is 5 seconds; Chromium caps the value at 7200 seconds (2 hours) and Firefox at 86400 seconds (24 hours).
Why can't Access-Control-Allow-Origin: * be used with cookies?
A credentialed request — fetch(url, { credentials: 'include' }) or XMLHttpRequest.withCredentials = true — is readable only when the response sends Access-Control-Allow-Credentials: true and an Access-Control-Allow-Origin equal to the requesting origin. For credentialed requests, a * in Access-Control-Allow-Headers, Access-Control-Allow-Methods, or Access-Control-Expose-Headers is treated as a literal name, not a wildcard.
The tempting fix is to reflect whatever Origin arrives — origin: true in the cors package that NestJS uses with the Express adapter. Combined with credentials: true, reflection lets any website read authenticated responses for a logged-in user. We use an exact allowlist instead: in main.ts, app.enableCors() gets an origin callback that reads a comma-separated CORS_ORIGIN environment variable into a Set, plus credentials: true, explicit methods and allowedHeaders, exposedHeaders for ETag and X-Request-Id, and maxAge: 7200.
• callback(null, false), not an Error. Returning false omits the CORS headers and lets the browser block the read; passing an Error turns the request into a 500 under Express.
• Exact strings only. A suffix regex like /example\.com$/ also matches evil-example.com, and allowlisting null admits every sandboxed iframe and file:// page, because those send Origin: null.
• Vary: Origin on every reflected response. The cors package adds it automatically; a hand-written middleware must add it too, or a CDN can serve one origin's Access-Control-Allow-Origin to another.
• Response headers are opt-in. JavaScript can read only CORS-safelisted response headers such as Content-Type and Cache-Control; ETag and X-Request-Id stay invisible until listed in Access-Control-Expose-Headers.
Why are cookies not sent on cross-origin requests?
A cookie crosses origins only when three layers agree: the request opts in, the cookie's SameSite attribute allows the context, and the browser's third-party cookie policy allows it. fetch() defaults to credentials: 'same-origin', so a cross-origin call needs credentials: 'include' before the browser sends any cookie or stores any Set-Cookie from the response.
When the frontend and API share a site — app.example.com and api.example.com — the request is same-site, and SameSite=Lax cookies are sent. Chrome treats a cookie with no SameSite attribute as Lax. A cookie set by api.example.com without a Domain attribute is host-only, so Next.js middleware on app.example.com cannot read it; Domain=example.com shares it with both hosts.
When they are different sites — such as two *.vercel.app deployments, because vercel.app is on the Public Suffix List — the session cookie becomes a third-party cookie. It needs SameSite=None; Secure, and even then Safari blocks third-party cookies by default and Chrome blocks them in Incognito. At Devya we no longer ship cross-site cookie auth: either the API moves to a subdomain of the frontend's site, or the frontend proxies it.
When should API calls go through a Next.js rewrite instead of CORS?
Proxy through a Next.js rewrite when one frontend owns the API; keep CORS when several origins, a mobile app, or third parties call the API directly. A rewrite lets the browser call /api/... on its own origin while the Next.js server forwards the request to the backend, so there is no preflight, no CORS header, and every cookie is first-party. In next.config.ts, the rewrites() function returns one rule: source /api/:path* with a destination of the API_URL environment variable followed by /api/:path*.
The browser then calls fetch('/api/me') with the default credentials: 'same-origin'. A rewrites array runs after Next.js checks its own routes, so a local app/api route handler with the same path still wins. Two backend changes come with the switch: session cookies must be host-only, because the browser rejects Domain=api.example.com on the frontend's host; and rate limiting or logging must read the client IP from X-Forwarded-For instead of the socket address.
• Preflight — direct CORS: on every non-simple request, cached up to Access-Control-Max-Age. Rewrite proxy: none, because requests are same-origin.
• Cookies — direct CORS: need credentials: 'include', and across sites need SameSite=None; Secure, which fails under third-party cookie blocking. Rewrite proxy: first-party, and SameSite=Lax works.
• Network path — direct CORS: browser to API. Rewrite proxy: browser to Next.js to API, one extra hop.
• Configuration — direct CORS: an origin allowlist on the API for every environment. Rewrite proxy: one API_URL variable on the frontend.
• Best for — direct CORS: public APIs, mobile apps, several frontends. Rewrite proxy: one frontend that owns its API.
Why does a 500 error show up as a CORS error?
A failed response without Access-Control-Allow-Origin is reported as a CORS error, which hides the real status code. JavaScript receives only a generic TypeError — Failed to fetch in Chrome — with no status. The usual causes are an error handler or gateway that responds before the CORS middleware runs, and nginx: add_header applies only to 2xx and 3xx responses unless the directive ends with always.
When a CORS error appears, we check the Network tab for the real status first, then replay the preflight with curl -i -X OPTIONS against the endpoint, sending Origin: app.example.com, Access-Control-Request-Method: POST, and Access-Control-Request-Headers: content-type. A correct response returns 204 with Access-Control-Allow-Origin: app.example.com, Access-Control-Allow-Credentials: true, and an Access-Control-Allow-Headers value that includes content-type. If those headers are missing, the fix belongs on the server or the proxy, not in the frontend fetch() call.
FAQ
Q: Why does an API work in Postman or curl but fail in the browser?
A: CORS is enforced only by browsers. curl and Postman never send preflights or check Access-Control-* headers, so a missing or wrong CORS configuration shows up only when page JavaScript calls the API.
Q: Does mode: 'no-cors' fix a CORS error?
A: No. mode: 'no-cors' returns an opaque response with status 0 and an unreadable body, and it allows only simple requests. It silences the error by hiding the data.
Q: Is CORS a CSRF protection?
A: No. Simple cross-origin requests, such as an HTML form POST, still reach the server with cookies attached; CORS only stops the attacker's page from reading the response. Use SameSite cookies, CSRF tokens, or an Origin header check against CSRF.
Further Reading
Backend Engineering
The Transactional Outbox Pattern in Postgres and NestJS: How We Ended Dual Writes Between the Database and the Queue
We replaced the save-then-publish dual write in our NestJS services with a transactional outbox in Postgres: the event row commits with the business change, a SKIP LOCKED relay publishes it, and consumers deduplicate by event ID. Here is the design, the failure modes it closes, and how we operate it.
Backend Engineering
Graceful Shutdown in Node.js and NestJS on Docker: SIGTERM, Draining, and Hook Order
Our NestJS containers were exiting with code 137 on deploy — SIGKILLed mid-request after Docker's 10-second grace period. Here is how we fixed signal delivery, enabled NestJS shutdown hooks, and moved connection cleanup to the hook that runs after the HTTP server drains.