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.
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.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.)
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.
// 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.
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.
// 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.
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 fixtures | MSW | Mockbird | |
|---|---|---|---|
| Universal load fetches | β (relative URL) | Two setups (worker + hook) | β plain URL |
| Pagination / filters / sort | You write them | You write them | Built in |
| Data persistence | Resets on restart | Resets per process | Persists, shared with team + CI |
| Delay / error / chaos injection | You write it | You write it | ?mock_delay / ?mock_status / ?mock_chaos |
| Usable outside your dev server | β | β | curl, Postman, mobile, deployed previews, teammates |
| Where it wins | Zero deps, fully offline | Offline unit tests, intercepts the real URL | β |
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.