A SvelteKit app whose SSR load functions fetch a cross-origin API will see Error: CORS error: No 'Access-Control-Allow-Origin' header is present on the requested resource in server-side error reporting. This is not a browser error and not a CORS misconfiguration, and the string exists nowhere in your source.
Verified against @sveltejs/kit 2.69.1 and @sentry/sveltekit 10.69.0.
Where it comes from
@sveltejs/kit/src/runtime/server/page/load_data.js (~line 298) deliberately simulates CORS server-side "for consistency with client-side behaviour". For any fetch through the fetch passed to load where url.origin !== event.url.origin:
const acao = response.headers.get('access-control-allow-origin');
if (!acao || (acao !== event.url.origin && acao !== '*')) {
throw new Error(`CORS error: ${acao ? 'Incorrect' : 'No'} 'Access-Control-Allow-Origin' header is present on the requested resource`);
}Am I affected? Only some of your loads are
This is the diagnostic that matters, and it is counter-intuitive.
The check lives inside create_universal_fetch, which wraps only the fetch handed to load. So:
- A load that passes the load event's
fetchinto your SDK (per-call opts, e.g. oazapfts' trailing{ fetch }) is wrapped → subject to the check. - A load that lets the SDK use its module-global
fetchis invisible to SvelteKit → never checked.
Passing the load's fetch is also the only way to get SSR response inlining and hydration replay, so it is the correct thing to do. The consequence: the loads you implemented correctly are precisely the ones that blow up with a bogus "CORS error" during an upstream blip, while the sloppy ones sail through. If the issue hits some routes and not others, this is why — stop looking for something special about those routes.
Why it fires intermittently rather than always
Server-to-server fetches send no Origin header, and most CORS middleware (Starlette's CORSMiddleware, for one) only adds access-control-allow-origin when the request has an Origin. Confirm in two curls:
curl -sI http://api.internal/healthcheck # no ACAO
curl -sI -H 'Origin: https://app.example' http://api.internal/health # ACAO presentSo whether it throws depends on which responses happen to carry the header. In practice it fires whenever an infrastructure error page answers instead of your app: a PaaS proxy 502, a CDN error page, a gateway timeout. Those never pass through your CORS middleware, so the honest 502/504 gets replaced by a misleading "CORS error" and any status-based error handling you wrote never runs.
Identifying it in an error tracker
Two tells, both decisive: the event's runtime tag is node, and (in Sentry) the transaction name uses the HTTP verb (GET /some/path) rather than the client route id. A browser-only explanation for a runtime: node event is always wrong. We watched one of these for twelve days assuming it was a client CORS fault.
Fix
In handleFetch, stamp the header on responses from your own API before returning them. CORS is a browser concept; a first-party server-to-server call has no security boundary to protect here, and browsers still run their own CORS check against the API's real headers.
function allow_ssr_cors(response: Response, origin: string): Response {
if (response.headers.get('access-control-allow-origin') === origin) return response;
const headers = new Headers(response.headers);
headers.set('access-control-allow-origin', origin);
// Null-body statuses (204/205/304) arrive with a null body, as the ctor requires.
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const response = await fetch(request);
return is_api ? allow_ssr_cors(response, event.url.origin) : response;
};Apply it to every return path in handleFetch, including retry paths. Passing response.body through preserves streaming instead of buffering.
Is stamping a header safe? Check your header filter
The obvious worry is that the added header leaks into the page or perturbs hydration replay. It does not, provided your filterSerializedResponseHeaders excludes it — and the default is to serialize nothing:
resolve(event, { filterSerializedResponseHeaders: (name) => name === 'content-type' })With that, the header is never written into the inlined <script data-sveltekit-fetched> block: no page weight, no effect on replay. If you widen that filter later, revisit this. (Related gotcha in the same machinery: SvelteKit patches response.headers.get on the proxied response and throws if load code reads a header the filter excludes. Your handleFetch code runs before that proxy is built, so it reads the raw response and is unaffected.)
General lesson
When an error string appears nowhere in your source, grep node_modules before concluding it came from the browser. Frameworks synthesise browser-flavoured errors on the server.