← All guides

Mock a REST API for your SvelteKit app in 60 seconds

You scaffolded with npx sv create, wrote the page, and now you need data. The real API isn't ready (it never is), so the choice is: hard-code an array, write throwaway +server.ts fixtures, or point your load functions at a real hosted API with realistic data β€” one whose error page and streaming skeletons you can actually trigger on demand. This guide does the third one.

Every snippet below was run before publishing, in a fresh npx sv create project (SvelteKit 2, Svelte 5 runes mode, TypeScript) β€” SSR output curl-checked, svelte-check clean, streaming timed. The two gotchas in section 3 come from that run, not from docs.

1. Get an API (10 seconds)

Click "try it" on the Mockbird landing page, or from a terminal:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -d '{"name":"sveltekit-demo","preset":"ecommerce"}'
# β†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}

The ecommerce preset seeds products, orders, customers and reviews with realistic data β€” names, prices, ratings, timestamps. No signup, no config, CORS on by default. Put the base URL in .env:

# .env
PUBLIC_API_URL=https://mockbird.mockbird.workers.dev/m/abc123

PUBLIC_-prefixed vars are the SvelteKit way to expose config to both server and client β€” exactly what a fetch base URL wants. (No terminal handy? Every example below also works against the public demo project: https://mockbird.mockbird.workers.dev/m/demo.)

2. Generate the TypeScript types (don't write them)

curl -o src/lib/api.types.ts https://mockbird.mockbird.workers.dev/m/abc123/types.ts

Interfaces for every resource, generated from the live schema. ?format=zod gets you Zod schemas instead if you validate at the boundary.

3. Fetch it from a universal load function

// src/routes/products/+page.ts
import { PUBLIC_API_URL } from '$env/static/public';
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, url }) => {
  const page = Number(url.searchParams.get('page') ?? '1');
  const res = await fetch(
    `${PUBLIC_API_URL}/products?_page=${page}&_limit=6&sortBy=price&order=desc`
  );
  if (!res.ok) error(res.status, `API said ${res.status}`);
  const products = await res.json();
  const total = Number(res.headers.get('x-total-count') ?? products.length);
  return { products, total, page };
};
<!-- src/routes/products/+page.svelte (Svelte 5 runes) -->
<script lang="ts">
  import type { PageData } from './$types';
  let { data }: { data: PageData } = $props();
</script>

<h1>Products ({data.total})</h1>
<ul>
  {#each data.products as p}
    <li>{p.name} β€” ${p.price}</li>
  {/each}
</ul>
{#if data.page * 6 < data.total}
  <a href="?page={data.page + 1}">Next page β†’</a>
{/if}

Use the fetch that load hands you, not the global one β€” it runs on the server for the first render, then in the browser for client-side navigations, and SvelteKit serializes the response into the page so the client doesn't re-fetch on hydration.

Gotcha #1 (this will 500 on you): reading a custom response header like x-total-count inside a universal load throws Failed to get response header during SSR, because SvelteKit only serializes an allowlist of headers to the client. Fix it once in a server hook:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) =>
  resolve(event, {
    filterSerializedResponseHeaders: (name) => name === 'x-total-count'
  });

Gotcha #2: new projects are Svelte 5 runes mode β€” export let data in a +page.svelte is a compile error now. It's let { data } = $props(), as above.

Pagination here is real: X-Total-Count reflects the live record count, so the "Next page" link genuinely disappears on the last page (with 30 products and _limit=6, page 5 hides it β€” we checked). Sorting, range filters (?price_gte=100), and full-text ?search= work the same way.

4. The parts SvelteKit makes special: +error.svelte and streamed skeletons you can trigger

SvelteKit gives you file conventions for the states everyone forgets to test β€” but with a hard-coded array or an instant local stub, your skeleton flashes by in 0ms and +error.svelte never renders at all. Mockbird misbehaves on demand.

Error page. The error(res.status, ...) call in the load above renders the nearest +error.svelte:

<!-- src/routes/products/+error.svelte -->
<script lang="ts">
  import { page } from '$app/state';  // Svelte 5; was $app/stores
</script>
<h1>{page.status}</h1>
<p>{page.error?.message}</p>

Trigger it with zero code changes by appending &mock_status=503 to the URL your load fetches β€” the mock returns a real 503 and your error page renders with status 503 (we ran exactly this). Every status 200–599 works, so you can rehearse the whole matrix: 401 β†’ login redirect, 404 β†’ not-found copy, 500 β†’ apology page.

Streaming. Return a promise from load without awaiting it and SvelteKit streams the result after the shell β€” the canonical skeleton pattern. With ?mock_delay you can finally see it:

// src/routes/reviews/+page.ts
export const load: PageLoad = ({ fetch }) => {
  return {
    // NOT awaited β†’ SvelteKit streams it; the shell renders instantly
    slow: fetch(`${PUBLIC_API_URL}/reviews?_limit=3&mock_delay=2000`)
      .then((r) => r.json())
  };
};
<!-- src/routes/reviews/+page.svelte -->
{#await data.slow}
  <p class="skeleton">Loading reviews…</p>
{:then reviews}
  <ul>{#each reviews as r}<li>{r.rating}β˜… β€” {r.body}</li>{/each}</ul>
{:catch}
  <p>Couldn't load reviews.</p>
{/await}

In our run the shell came back in 62ms with the skeleton in the SSR payload, and the reviews streamed in 2 seconds later. Combine with ?mock_chaos=0.3 to make a third of requests fail randomly and watch your {:catch} branch earn its keep β€” that's a whole guide of its own.

5. Writes: form actions against the mock

// src/routes/products/new/+page.server.ts
import { PUBLIC_API_URL } from '$env/static/public';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request, fetch }) => {
    const form = await request.formData();
    const res = await fetch(`${PUBLIC_API_URL}/products`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        name: form.get('name'),
        price: Number(form.get('price'))
      })
    });
    if (!res.ok) return fail(res.status, { message: 'API rejected it' });
    redirect(303, '/products');
  }
};

The POST returns 201 with the created record, it persists, and the redirected /products page shows it β€” because the mock is a real database, not a canned response. Want the sad path too? Turn on write validation and your fail(422, ...) branch gets real per-field errors to render.

Broke the dataset while experimenting? Re-seed from the dashboard, or save a snapshot first and restore it in your E2E beforeEach β€” every Playwright worker can even pin its own snapshot with one header.

What about +server.ts fixtures or MSW?

Both are good tools; here's the honest split.

Hand-rolled +server.ts fixtures (a src/routes/api/products/+server.ts returning JSON) are the zero-dependency move, and for one endpoint they're fine. But now you're writing the mock: pagination, filtering, sorting, persistence, and error simulation are all code you maintain, the data resets on every restart, and it only exists while your dev server runs β€” the teammate building the mobile app or the backend gets nothing.

MSW is excellent for unit tests, and its Node interception story in SvelteKit is less painful than Next's β€” but you still wire it through the server hook and maintain handler code, the browser worker and the server interceptor are separate setups, and state resets per process. A hosted mock sidesteps the whole problem: your load just fetches a URL β€” same data server-side, client-side, in Playwright, and on your teammate's machine. More in our MSW comparison.

+server.ts fixturesMSWMockbird
Universal load fetchesβœ” (relative URL)Two setups (worker + hook)βœ” plain URL
Pagination / filters / sortYou write themYou write themBuilt in
Data persistenceResets on restartResets per processPersists, shared with team + CI
Delay / error / chaos injectionYou write itYou write it?mock_delay / ?mock_status / ?mock_chaos
Usable outside your dev serverβ€”β€”curl, Postman, mobile, deployed previews, teammates
Where it winsZero deps, fully offlineOffline unit tests, intercepts the real URLβ€”

6. Ship day: swap the env var

Mockbird follows plain REST conventions, so moving to the real backend is changing PUBLIC_API_URL. Nothing else changes β€” the hooks.server.ts line keeps working if your real API sends X-Total-Count too. If the backend team wants a contract to build against, hand them the mock's live spec β€” https://mockbird.mockbird.workers.dev/m/abc123/openapi.json β€” or the generated Postman collection.

The same flow works for React (SPA), Vue, Next.js, Flutter, Angular and React Native / Expo. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, reviews…) in the dashboard β€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.