Munchable ships to two places from one React Native codebase. There is an Android app, and there is app.munchable.app, which is the same Expo project built for the web. The marketing site at munchable.app is a separate Next.js app, and it owns the API.
That arrangement produces a question worth asking out loud before writing any CORS config: who actually calls this API from a browser?
Going through the list:
- The Next.js site itself. Same origin, so CORS never applies.
- The native Android app. It has no browser and no origin, so it never sends
Originand never triggers a preflight. Every CORS rule in the world is invisible to it. - The Expo app running in a browser.
app.munchable.appcallingmunchable.app, and on a developer machine, the Metro dev server onlocalhost:8081callinglocalhost:3000.
So the only cross-origin browser caller of this API is our own app, and the entire policy exists for that one case. Before it existed, route handlers emitted no CORS headers at all and every preflight from the Expo web build failed, which is a confusing bug the first time you hit it because the native app works perfectly.
Never allow credentials
The policy is: reflect the request Origin when it is allowed, and never send Access-Control-Allow-Credentials.
That second half sounds like a limitation and is actually the point. The client in a browser carries a bearer token in an Authorization header, which travels fine without credentials mode. The cookie-based session that the Next.js site uses is a different mechanism, and without the credentials header a browser will refuse to attach those cookies to a cross-origin request at all.
So the cookie session physically cannot be replayed from another origin, whatever a page manages to talk our API into. Skipping one header removes a whole class of cross-site request forgery from consideration rather than mitigating it.
It is also why Access-Control-Allow-Origin reflects the origin instead of being a wildcard. A wildcard would be simpler, but reflecting means the allowed set is an explicit decision, and a reflected origin is required the moment you would ever want credentials.
What counts as allowed
export function isAllowedOrigin(origin: string | null): origin is string {
const url = parseUrl(origin ?? undefined);
if (!url) return false; // absent, malformed, or the opaque "null" origin
if (url.origin === SITE.origin || EXTRA_ORIGINS.has(url.origin)) return true;
if (
url.protocol === 'https:' &&
(url.hostname === SITE.hostname || url.hostname.endsWith(`.${SITE.hostname}`))
) {
return true;
}
return !IS_PRODUCTION && LOOPBACK_ORIGIN.test(url.origin);
}
Four things in there earn their place.
Parse, do not compare strings. new URL() normalises the value and rejects junk. String prefix matching on origins is how munchable.app.evil.com gets allowed.
The subdomain rule requires https explicitly, and checks endsWith('.' + hostname) with the dot included. Without the leading dot, notmunchable.app matches. This is the single most common bug in hand-rolled origin checks.
Loopback is allowed outside production only. Metro picks whatever port is free, so pinning one port in config means a developer whose 8081 was busy spends twenty minutes on a CORS error. The production guard is what makes that convenience safe.
The literal string "null" is an origin, sent by sandboxed iframes and some file-protocol contexts. parseUrl returns null for it, so it falls through to false rather than being compared as text.
Denial is silence
When an origin is not allowed, nothing is refused. The response simply goes back without any Access-Control-Allow-* headers:
const origin = request.headers.get('origin');
if (!isAllowedOrigin(origin)) return response;
The browser is the thing that enforces CORS, so the absence of the header is the enforcement. Returning a 403 instead would be worse: it tells a scripted caller, which is not bound by CORS in the first place, exactly what your policy is, while doing nothing extra to the browser.
The preflight works the same way. It always answers 204, with the allow headers only when the origin passes.
Vary, or your CDN will serve the wrong answer
response.headers.append('Vary', 'Origin');
That line is appended before the origin is even checked, which is deliberate. The response body may be identical, but the headers differ by origin, so any shared cache has to key on it. Miss this and a cached response carrying one origin's allow header gets served to another, which fails in a way that is maddening to reproduce because it depends on who warmed the cache.
The preflight adds Access-Control-Request-Headers to its Vary for the same reason, since the allowed header list is reflected from the request.
Headers a client cannot read do not exist
By default, JavaScript on a cross-origin response can read a handful of headers and nothing else. Everything else is there in the network tab and unreadable from code, which is a genuinely disorienting first encounter.
So the policy names the ones the client needs:
const EXPOSED_HEADERS =
'Retry-After, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, ETag, x-taxonomy-version';
The rate limit headers let the client back off properly instead of treating a 429 as a random failure. ETag and x-taxonomy-version are how the app learns that its on-device ingredient data is behind the server's, which is the mechanism that lets curation reach a phone without an app release.
That list is a real coupling between the CORS module and two features in other parts of the codebase, and it is the kind of coupling that breaks quietly. A header added to a response is invisible to the browser client until it is added here too.
Preflight caching is a request, not a promise
const PREFLIGHT_MAX_AGE = '86400';
Browsers cap this. Chromium's ceiling is two hours and Firefox's is twenty four, so asking for a day means "as long as you are willing to". It is still worth asking for, because the alternative is an extra round trip in front of a meaningful fraction of requests.
One place, not thirty
None of this lives in route handlers. It is applied in the proxy, which is Next.js's renamed middleware convention:
export async function proxy(request: NextRequest) {
const isApi = request.nextUrl.pathname.startsWith('/api/');
if (isApi && request.method === 'OPTIONS') return corsPreflightResponse(request);
const response = await updateSession(request);
return isApi ? applyCorsHeaders(request, response) : response;
}
A policy that every route has to remember is a policy with a hole in it, and the hole is always in the route added last. Two lines here cover every current route and every future one.
The webhook routes are excluded from the matcher entirely. They are server-to-server, unauthenticated, and verified by signature, so there is no browser involved and no CORS question to answer. Excluding them also keeps a session refresh off a path that has no session.
The other half: the app is deliberately not in the index
Since the Expo build is served as a static single-page app, its hosting config rewrites every path to index.html, caches the hashed bundle immutably for a year, and makes everything else revalidate. Standard SPA hosting.
It also sends this on every response:
X-Robots-Tag: noindex, nofollow
The marketing site is the surface that is meant to rank, with hundreds of generated pages behind it. The app subdomain is an app shell that renders nothing useful without a session. Leaving it crawlable gets you a thin, duplicate, permanently unhelpful result competing against the pages you actually wrote.
Worth the reminder that this is a header on a different deployment, not a line in the site's robots.txt, because the two hosts are two different properties and only one of them has anything to say to a crawler.
Go and look
- Open app.munchable.app with the network tab open. The requests going to munchable.app are cross-origin, carry an
Originheader, and come back with a reflected allow header, an expose-headers list, andVary: Origin. No cookies attached. - Compare with munchable.app, where the same API is same-origin and none of those headers appear at all.
- The
x-taxonomy-versionheader in that expose list is the one that tells the app its ingredient knowledge is stale. There is a separate post on how that snapshot is versioned and delivered, and what it produces is visible on the ingredient answers pages.
The summary I would give a past version of myself: write down every browser that will ever call your API before you write any CORS config. The list is usually shorter than you think, and once it is written the policy is mostly a matter of not allowing anything that is not on it.
Top comments (0)