Skip to content

Fixing CORS errors in SvelteKit and Astro

Where SvelteKit and Astro code runs decides whether CORS applies. Fix CORS errors with server endpoints, load functions, middleware, dev proxies or a proxy.

The ProxifyEdge team 5 min read

SvelteKit and Astro both blur the line between server and browser code, which is great for productivity and confusing when a CORS error appears. The key to fixing CORS errors in SvelteKit and Astro is the same in both: work out whether the failing request is made by the server or by the browser. Only browser requests are subject to CORS.

This guide walks through where each framework runs your code, then the fixes in order of preference: move the call to the server, add CORS headers to your own endpoints, use the dev-server proxy while developing, and use a proxy when there is no server at all.

Where the request runs decides everything

CORS is enforced by browsers, on responses that script wants to read. A request made from Node, a serverless function or an edge runtime is never blocked by CORS. If the mechanics are unfamiliar, start with what CORS is.

In both frameworks, that means:

  • Code that runs on the server (during server rendering, in endpoints, in server-only load functions, at build time) can call any API without CORS errors.
  • Code that runs in the browser (client-side components, <script> tags, client navigation) needs the API to allow your origin.

The subtle cases are code paths that run in both places.

SvelteKit

Universal load runs in the browser too

SvelteKit has two kinds of load function. A +page.server.ts load runs only on the server. A +page.ts load is universal: it runs on the server for the first page load and in the browser for client-side navigation afterwards.

That explains a common report: “the page works when I refresh, but the data fails when I click a link”. The refresh ran load on the server, where CORS does not exist; the click ran it in the browser.

// src/routes/weather/+page.ts — universal: runs in the browser on navigation.
export async function load({ fetch }) {
  const response = await fetch('https://api.example.com/forecast?city=berlin');
  return { forecast: await response.json() };
}

Fix: move the call into a server load or endpoint

Rename the file to +page.server.ts and the call always happens on the server. Secrets can now come from private environment variables, and the browser only ever talks to your own origin.

// src/routes/weather/+page.server.ts — server only.
import { FORECAST_API_KEY } from '$env/static/private';

export async function load({ fetch }) {
  const response = await fetch('https://api.example.com/forecast?city=berlin', {
    headers: { Authorization: `Bearer ${FORECAST_API_KEY}` },
  });
  return { forecast: await response.json() };
}

When client code needs to call the data on demand, put it behind a +server.ts endpoint on your own origin:

// src/routes/api/forecast/+server.ts
import { json } from '@sveltejs/kit';
import { FORECAST_API_KEY } from '$env/static/private';

export async function GET({ url, fetch }) {
  const city = url.searchParams.get('city') ?? 'berlin';
  const upstream = await fetch(`https://api.example.com/forecast?city=${encodeURIComponent(city)}`, {
    headers: { Authorization: `Bearer ${FORECAST_API_KEY}` },
  });
  return json(await upstream.json(), { status: upstream.status });
}

If your SvelteKit app is the API

When another origin calls your SvelteKit endpoints, add CORS headers and answer the preflight. A handle hook keeps it in one place:

// src/hooks.server.ts
const ALLOWED = new Set(['https://app.example.org']);

export async function handle({ event, resolve }) {
  const origin = event.request.headers.get('origin');
  const allowed = origin && ALLOWED.has(origin);

  if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) {
    return new Response(null, {
      status: 204,
      headers: allowed
        ? {
            'Access-Control-Allow-Origin': origin,
            'Access-Control-Allow-Methods': 'GET, POST',
            'Access-Control-Allow-Headers': 'Content-Type',
            Vary: 'Origin',
          }
        : { Vary: 'Origin' },
    });
  }

  const response = await resolve(event);
  if (allowed && event.url.pathname.startsWith('/api/')) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.append('Vary', 'Origin');
  }
  return response;
}

Allow named origins, not *, especially if requests carry cookies; CORS with credentials explains why.

Astro

Frontmatter runs on the server, scripts run in the browser

In an Astro component, the code between the --- fences runs on the server: at build time for prerendered pages, or per request for pages rendered on demand. A fetch there never hits CORS. Code in a <script> tag, or in a framework component hydrated with a client: directive, runs in the browser and does.

---
// Runs on the server: no CORS.
const forecast = await fetch('https://api.example.com/forecast?city=berlin').then((r) => r.json());
---

<p>{forecast.summary}</p>

<script>
  // Runs in the browser: CORS applies.
  fetch('https://api.example.com/forecast?city=paris');
</script>

Fix: an endpoint rendered on demand

For data the browser needs after the page loads, add an endpoint. With a server adapter installed, mark it to render on demand so it runs per request:

// src/pages/api/forecast.ts
import type { APIRoute } from 'astro';

export const prerender = false;

export const GET: APIRoute = async ({ url }) => {
  const city = url.searchParams.get('city') ?? 'berlin';
  const upstream = await fetch(`https://api.example.com/forecast?city=${encodeURIComponent(city)}`, {
    headers: { Authorization: `Bearer ${import.meta.env.FORECAST_API_KEY}` },
  });
  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: { 'Content-Type': upstream.headers.get('content-type') ?? 'application/json' },
  });
};

If your Astro site is itself the API for another origin, export an OPTIONS handler from the endpoint, or add the headers for /api/ paths in src/middleware.ts, following the same allowlist pattern as the SvelteKit hook above.

The dev-server proxy is for development only

Both frameworks run on Vite, whose dev server can forward a path to another host. That makes requests same-origin while you develop:

// vite.config.ts (SvelteKit) — in Astro, the same object goes under `vite` in astro.config.mjs.
export default {
  server: {
    proxy: {
      '/forecast-api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/forecast-api/, ''),
      },
    },
  },
};

This only exists while vite dev is running. A production build does not include it, which is why “it worked locally” is such a common CORS story. The React and Vite guide covers the same trap from the React side.

Static builds: no server to move the call to

With SvelteKit’s static adapter or Astro’s static output and no adapter, every page is generated at build time and there is no server at request time. Browser code that needs live data from a third-party API then has two options:

  1. Ask the API owner to allow your origin, if that is realistic.
  2. Send the request through a CORS proxy with a key locked to your origin.

With ProxifyEdge, that is a URL prefix:

const target = 'https://api.example.com/forecast?city=berlin';
const response = await fetch(`https://api.proxifyedge.com/proxy?url=${encodeURIComponent(target)}`, {
  headers: { 'X-API-Key': 'pk_your_public_key' },
});

A live key only answers the origins you list, so it is safe in a public bundle, and an upstream credential can stay in the secrets vault instead of your client code. For the reasoning behind that, see why you can’t hide an API key in frontend code.

Common pitfalls

A few mistakes come up again and again in both frameworks:

  • Prerendering an endpoint by accident. In Astro’s static output, an endpoint without export const prerender = false is generated once at build time. Its response is frozen, query parameters are ignored, and a POST handler never runs. If an endpoint needs request data, render it on demand.
  • Forgetting the preflight. A JSON POST or a custom header triggers an OPTIONS request before the real one. If your endpoint only handles GET and POST, the preflight gets a 404 or 405 and the browser stops there. The preflight guide lists exactly which requests trigger one.
  • Adding CORS headers only on success. If your handler returns a 500 without Access-Control-Allow-Origin, the browser reports a CORS error and hides the real failure. Set the headers on error responses too, or in middleware that wraps every response.
  • Leaking private environment variables. In SvelteKit, $env/static/public and $env/dynamic/public reach the browser; in Astro, PUBLIC_ variables are inlined into client code. Keep API secrets in private variables and use them only in server code.

Key takeaways

  • CORS only affects requests made by the browser; find out where your failing request runs.
  • In SvelteKit, +page.ts loads also run in the browser; use +page.server.ts or a +server.ts endpoint for third-party calls.
  • In Astro, frontmatter runs on the server; <script> tags and hydrated components run in the browser.
  • The Vite dev proxy hides CORS problems in development only.
  • For fully static builds, use an API that allows your origin or a proxy with an origin-locked key.

Are you sure?