Same-origin app → its own backend
No CORS. No headers. Nothing. Your SaaS UI fetching from the domain that served it is same-origin, which is the monolith default and the course’s normal case. Most of the code you’ll write never touches any of this.
This lesson is about CORS, the protocol of server response headers that decides which origins a browser will let read a cross-origin response.
The last lesson ended with an open question. You learned that the same-origin policy doesn’t stop a cross-origin request from being sent: the browser sends the request and lets the response come back, but it refuses to let the cross-origin page read that response. The browser also attaches an Origin header to the request, which asks the server a question on the page’s behalf: who is calling? The last lesson taught you that question. This one teaches the answer.
The answer is CORS , or Cross-Origin Resource Sharing, the protocol a server uses to say “this response may be read by these specific origins.” By the end of this lesson you’ll be able to look at any fetch and predict whether it triggers a second hidden request. You’ll know the four headers a production server owes the browser, how to avoid the most common production CORS bug, and how to read the red console error when something goes wrong so you know exactly which header to fix.
One thing is worth settling up front, so you don’t worry about over-applying any of this. A same-origin app calling its own backend, such as your SaaS UI fetching from the same domain it was served from, uses none of CORS. No headers, no preflight, nothing. CORS comes into play only when the request reaches across origins: to a dedicated API subdomain, a third-party API, a browser extension, or a marketing site calling your app. The last section draws that boundary precisely. Until then, assume CORS matters only when origins differ.
The whole lesson rests on one idea that beginners often get backwards: CORS is enforced by the browser but configured on the server.
It helps to separate who does what. The browser is the enforcer: it blocks the read when the rules aren’t met. The server is the authority: it decides, header by header, which origins are allowed and what they may do. The Access-Control-* response headers are the contract between them. Your client-side fetch code only watches from the sidelines. It never sets an Access-Control-* header, because those are response headers, and the client sends requests, not responses.
This is why “fixing CORS in the frontend” is a mistake that costs people hours: it can’t be done. A CORS error shows up in the browser console, on the client, which tempts people into reaching for the client fetch call. But that error is just the browser reporting that the server’s answer was insufficient, so the fix lives on the server, every time.
CORS comes in two shapes, and the rest of the lesson builds on the difference between them. In a simple request , the browser sends the request straight away, then checks the response headers afterward to decide whether the page may read the body. In a preflighted request , the browser asks permission first: it sends a separate OPTIONS request, waits for the server to authorize the real request’s method and headers, and only then sends the real one. The next two sections take these in turn: first how to tell which one you’ll get, then what the preflight actually looks like on the wire.
Predicting which path a request takes is the practical skill, so start there. A request skips the preflight and goes straight out, which makes it “simple,” only when every one of the conditions below holds at once. As soon as a request falls outside any row, the browser preflights it.
| Dimension | Simple (no preflight) | Anything else preflights |
| --- | --- | --- |
| Method | GET, HEAD, POST | PUT, PATCH, DELETE, … |
| Headers | only CORS-safelisted: Accept, Accept-Language, Content-Language, Content-Type, Range | any other header (e.g. Authorization) |
| Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain | application/json, anything else |
You could memorize that table, but you don’t need to. It collapses into one rule that covers nearly every request your SaaS will ever make:
The reason is in the third row: application/json is not on the safelist. The only Content-Type values that count as simple are the three a plain HTML <form> can produce on its own. Send JSON and you’ve left the safelist, so the browser preflights. Headers work the same way: attach an Authorization header to carry a token and you’ve added a non-safelisted header, which forces a preflight too. So any call that sends JSON or carries an auth token preflights, and that covers the entire authenticated API surface of a typical app. (A ReadableStream request body also forces a preflight, but you’ll rarely send one, so don’t dwell on it.)
What survives as a simple request, then, is the older, embed-shaped web: a <form> POST submitting multipart/form-data, or an image beacon firing a GET. Those stay simple. For everything your app deliberately does with fetch, assume preflight.
Let’s pin that down before moving on. For each statement, decide whether the request preflights.
For each fetch, decide whether the browser preflights it. (Assume each is cross-origin.) Mark each statement True or False.
A GET request with no custom headers and no body does not preflight.
GET is a simple method and there are no non-safelisted headers, so it qualifies as a simple request. The browser sends it directly and checks the response headers afterward.A POST request with Content-Type: application/json preflights.
application/json is not a CORS-safelisted content type, so the request leaves the simple set and the browser sends an OPTIONS first. This is nearly every API call your UI makes.A GET request carrying an Authorization: Bearer … header preflights.
Authorization is not a safelisted header. Any non-safelisted header forces a preflight, so token-bearing calls always preflight too.So your JSON POST preflights. What does that actually look like? This is the dance the lesson is named for: a permission-check round trip that happens before your real request, entirely out of your JavaScript’s sight.
Here’s the full sequence. Read it from top to bottom, where each arrow is a real message on the wire.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 19px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 16px !important; }'} }%%
sequenceDiagram
participant P as Page (JS)
participant B as Browser
participant S as Server<br/>api.acme.com
P->>B: fetch(POST /invoices, Content-Type: application/json)
Note over B: JSON Content-Type → not simple → must preflight first
rect rgba(56, 189, 248, 0.12)
Note over B,S: Preflight — a separate OPTIONS your code never wrote
B->>S: OPTIONS /invoices · Origin: https://app.acme.com<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: content-type
S-->>B: 204 No Content · Access-Control-Allow-Origin: https://app.acme.com<br/>Access-Control-Allow-Methods: POST · Access-Control-Allow-Headers: content-type
end
Note over B: Allow-* headers cover the request → authorized
rect rgba(34, 197, 94, 0.12)
Note over B,S: The real request — only now does it fire
B->>S: POST /invoices · Origin: https://app.acme.com + JSON body
S-->>B: 200 OK + body · Access-Control-Allow-Origin: https://app.acme.com
end
B->>P: resolve the fetch promise with the response body
Note over B,S: If the preflight is NOT authorized (wrong origin, missing method or header), the browser<br/>cancels the real request — the POST never fires — and the fetch promise rejects with a generic<br/>TypeError. The actionable reason appears only in the console. A few pieces are worth noticing. The OPTIONS/204 pair in the blue band is the preflight: a separate OPTIONS request your code never wrote and never sees. In it, the browser previews what it’s about to do. Access-Control-Request-Method announces the method, Access-Control-Request-Headers lists the non-safelisted headers, and the server answers with the matching Access-Control-Allow-* headers. Only if that answer covers the request does the browser send the real POST in the green band below.
The failure case is the part that trips people up. If the preflight response doesn’t authorize the request, the browser cancels the real request before it’s sent, so the server never even sees your POST, and your fetch promise rejects. The catch is that it rejects with a generic, unhelpful TypeError. The real reason is printed separately, in red, in the console, and the last section teaches you to read it.
The most useful thing to carry from this diagram is that the preflight is a real, separate network request, not a metaphor. Open your browser’s Network panel and you’ll see two rows: an OPTIONS, then your POST. The waterfall below shows that happening step by step.
Two rows for one fetch. Once you’ve seen this in DevTools, the preflight stops being abstract. When a cross-origin call misbehaves, your first question becomes “which of the two rows failed, the OPTIONS or the real one?”, and that question alone narrows the bug down fast.
Now for the server’s side of the contract. To let a cross-origin page read a response, a production server sends up to four Access-Control-* response headers. Each one maps to a specific failure if it’s missing, so read the middle column as closely as the others: that pairing of header to symptom is what makes this table worth returning to.
| Header | What it does / failure mode | Example |
| --- | --- | --- |
| Access-Control-Allow-Origin | Names which origin may read the response. Missing → the browser refuses the read, and the page sees the canonical “No ‘Access-Control-Allow-Origin’ header” error. | https://app.acme.com (the exact origin, echoed back; * works only without credentials, as the trap below explains) |
| Access-Control-Allow-Methods | (preflight only) Lists the methods the real request may use. Missing → a PUT, PATCH, or DELETE is rejected at the preflight. | GET, POST, PUT, DELETE, PATCH |
| Access-Control-Allow-Headers | (preflight only) Lists the request headers the real request may send. Missing → any non-safelisted header trips the preflight, including the JSON Content-Type or an Authorization token. | content-type, authorization |
| Access-Control-Allow-Credentials | Opts the response into being readable when the request carries credentials. Pairs with the client’s credentials: 'include'. Missing → cookies and auth aren’t usable, and the credentialed response is blocked. | true |
Two more headers are worth a mention but sit one tier down, since you reach for them less often:
Access-Control-Max-Age: 86400 caches the preflight result so the browser stops sending an OPTIONS before every call. Without it, every JSON request pays for two round trips. There’s a catch worth knowing: Chrome caps this at 7200 seconds (2 hours) and Firefox at 86400 (24 hours), so a larger value is silently clamped, and asking for a week buys you only two hours in Chrome. A common approach is to pair a modest max-age with an exact-echo origin and move on.Access-Control-Expose-Headers: X-Total-Count, X-Page lets your JavaScript read response headers that aren’t on the default exposed set (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma). The classic reason to reach for it is pagination: you return a paginated list and put the total count in X-Total-Count. Without exposing that header, the browser hides it from your code even though it arrived.This one gets its own section because it is the most common production CORS bug, and once you can name it you can usually spot it on sight.
The rule itself is simple: Access-Control-Allow-Origin: *, the wildcard that means “any origin may read this,” is legal only when the request is not credentialed. A credentialed request is one where the client set credentials: 'include', telling the browser to attach the user’s cookies. The instant a request is credentialed and the server answers with *, the browser refuses the response and the page sees “…the value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ’*’ when the request’s credentials mode is ‘include’.”
The reasoning makes sense once you see it: * means “anyone can read this,” and “anyone can read this with the user’s cookies attached” would hand every site on the internet the keys to the user’s session. The browser forbids that combination outright.
The fix is to stop using the wildcard and name the caller exactly. Compare the broken and fixed handlers below.
export const GET = async (req: Request) => { const invoices = await listInvoices(); return Response.json(invoices, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true', }, });};Blocked the moment the client sends credentials: 'include'. A wildcard origin plus credentials is the one combination the browser refuses, because * says anyone, and anyone reading the response with the user’s cookies attached is exactly what the rule exists to prevent.
const allowedOrigins = new Set(['https://app.acme.com']);
export const GET = async (req: Request) => { const origin = req.headers.get('Origin'); const allowOrigin = origin && allowedOrigins.has(origin) ? origin : '';
const invoices = await listInvoices(); return Response.json(invoices, { headers: { 'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Credentials': 'true', Vary: 'Origin', }, });};Validate the incoming Origin against an allow-list, then echo the exact value back. The browser now sees its own origin named explicitly, which is legal alongside credentials. The Vary: Origin line is mandatory here, as explained below.
That Vary: Origin line in the fixed version isn’t decoration, and it’s worth understanding why it has to be there.
You’ve now seen that almost everything in CORS lives on the server. So what does the client control? Less than you might expect: three fetch options, and for all three the default is the right pick for a first-party SaaS. The client never touches an Access-Control-* header, and these three options are the only ones that interact with CORS at all.
| Knob | Default | What to know |
| --- | --- | --- |
| mode | 'cors' | The only useful value for cross-origin. 'same-origin' rejects cross-origin requests outright; 'no-cors' sends the request but hands you an opaque response you can’t read, which catches people out. Leave it on the default. |
| credentials | 'same-origin' | Cookies attach only on same-origin requests. 'include' attaches them cross-origin too, and requires Access-Control-Allow-Credentials: true on the server. 'omit' never attaches. |
| Origin header | (browser-set) | The browser writes it automatically; your code can never set or forge it. A server can validate it, but can’t trust it from a non-browser caller, since curl or a script can send any Origin it likes. |
The credentials default is worth dwelling on, because it’s the same default that kept the monolith case quiet in the last lesson. With credentials: 'same-origin', your SaaS UI calling its own backend carries the session cookie without a single line of ceremony: same origin, cookies attach, done. You reach for 'include' only when you deliberately want a cross-origin authenticated call. That’s the case where you’ve decided to send the user’s cookies to a different origin, and the server has to opt in with Access-Control-Allow-Credentials: true to match.
That’s the whole client surface for CORS. The full fetch story, including request bodies, methods, status handling, and AbortSignal for cancellation, comes in a couple of chapters, when we cover fetch properly. Here, these three knobs are all that touch the cross-origin contract.
This is the payoff the last lesson promised. When a cross-origin call fails the CORS check, your fetch promise rejects with the unhelpful TypeError: Failed to fetch: no origin, no header, no clue. The browser does tell you exactly what went wrong, but it tells you in a separate red message in the console, not in the error your code catches.
So reading that red string is the real skill. The good news is that there are only a handful of canonical messages, and each one maps to a fix you’ve already learned in this lesson. Match each error to what it’s telling you to do on the server.
Each red console string points at one server-side fix. Match the error to the change that resolves it. Click an item on the left, then its match on the right. Press Check when done.
Access-Control-Allow-Origin header is present on the requested resource.Access-Control-Allow-Origin in the route handler.'*' when the request’s credentials mode is 'include'.Vary: Origin.OPTIONS handler returned a non-2xx status — return 204 with the CORS headers.authorization is not allowed by Access-Control-Allow-Headers in preflight response.authorization (or whichever header is named) to Access-Control-Allow-Headers.Read those four until going from symptom to fix is automatic, because in production that step is most of the debugging. Every one of the fixes is a server change, which is this lesson’s whole point arriving in the place you’ll actually meet it: the console.
You haven’t built a Next.js backend yet, since that’s Unit 4 and beyond, so treat this section as something to recognize rather than to write from scratch. The goal is that when you do write a cross-origin endpoint, this shape looks familiar and you know what each line is for.
The recommended pattern keeps the allow-list next to the route it guards, in a small helper. That’s two files: a lib/cors.ts that holds the policy, and the Route Handler that uses it.
const allowedOrigins = new Set(['https://app.acme.com']);
export const corsHeaders = (origin: string | null): HeadersInit => { const allowOrigin = origin && allowedOrigins.has(origin) ? origin : ''; return { 'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'content-type, authorization', 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Max-Age': '7200', Vary: 'Origin', };};The policy in one place. Validate the origin against the allow-list, echo it back, and pack the four headers plus Vary: Origin and a modest Max-Age. Every route that needs CORS reads from this one function.
import { corsHeaders } from '@/lib/cors';
export const OPTIONS = (req: Request) => new Response(null, { status: 204, headers: corsHeaders(req.headers.get('Origin')), });
export const GET = async (req: Request) => { const invoices = await listInvoices(); return Response.json(invoices, { headers: corsHeaders(req.headers.get('Origin')), });};One file per route, one named export per method. A route.ts dispatches by HTTP method through its named exports: OPTIONS answers the preflight, and GET answers the real request. Both pull the same headers from the helper.
Two things are worth locking in from that handler.
First, the OPTIONS export is the preflight handler. It’s the server side of the preflight round trip in the sequence diagram: the browser’s OPTIONS arrives, and this function answers 204 with an empty body and the four headers. Forget to export OPTIONS, or return a non-2xx from it, and you get canonical error #3, “Response to preflight request… does not have HTTP ok status.” This handler is easy to forget and easy to get wrong.
Second, why this lives per-route rather than app-wide. Next.js does let you set headers globally in next.config.ts, but that’s a coarse, every-route tool, and the security baseline in Unit 16 is where it belongs. The CORS allow-list belongs next to the route it guards, because which origins may read invoices is a property of the invoices route, not of the whole app.
You’ve learned the tool. The matching judgment is knowing when you don’t need it. CORS only enters the picture when a browser makes a cross-origin request and wants to read the response. Plenty of common situations don’t meet that bar, and mistaking one of them for a CORS problem leaves you debugging the wrong layer. Here are the ones worth recognizing.
Same-origin app → its own backend
No CORS. No headers. Nothing. Your SaaS UI fetching from the domain that served it is same-origin, which is the monolith default and the course’s normal case. Most of the code you’ll write never touches any of this.
API subdomain
app.acme.com calling api.acme.com is cross-origin (different host) but same-site (same registrable domain). CORS is required, yet SameSite=Lax cookies still travel, because the site matches. The cookie side is the next chapter’s job.
Server-to-server fetch
A Next.js Route Handler or Server Action calling a third-party API has no browser, no Origin header, and no CORS check at all. This is the escape hatch: when a third party ships no CORS, fetch it from your server instead of the client.
Reverse proxy / rewrites
Expose a third party under your own origin (a Next.js rewrites() rule) and the call collapses from cross-origin to same-origin, so CORS disappears, at the cost of a network hop. This is the move when a third party offers no CORS and a full server proxy is overkill.
Browser extension / null origin
Content scripts send Origin: null. You have to decide deliberately whether to allow-list null or refuse it, since there’s no exact origin to echo. This is edge-case awareness; you’ll rarely hit it.
One pattern runs through that list. Two of these cases (server-to-server, reverse proxy) make CORS vanish by removing the browser from the equation, so they’re the alternatives to configuring CORS at all. When a third-party API ships no CORS headers, the answer is usually not “convince them to add CORS”; it’s “call it from my server.”
This is optional, but worth five minutes if you’re curious: it’s the diagram made real, the two-row waterfall happening on your own machine instead of on a slide.
Spin up your own minimal Route Handler when you reach Unit 4, call it from a page on a different port, and open the Network panel. You’ll watch the OPTIONS preflight resolve, then the real request fire, exactly like the diagram. Until then, MDN walks the same OPTIONS round trip header by header, including the failure path.
The OPTIONS preflight on the wire: the Access-Control-Request-* headers the browser sends and the Access-Control-Allow-* answer that authorizes the real request.
The full set of headers and the exact error strings are reference material you’ll come back to, and MDN is the canonical source for both.
The full header reference, the simple-request criteria, and every error string verbatim.
The spec-precise definition of preflighting and the safelist, for the reader who wants the source of truth.
Lydia Hallie animates the whole flow frame by frame: simple vs. preflighted requests, the Origin question, and the Access-Control-* answer.
Point it at any URL, pick a method and origin, and watch the preflight and Access-Control-* headers come back: the diagram made interactive.