Svelte / SvelteKit fetch vs tanstack-fetch
Stack
Svelte 4+ / SvelteKit · tanstack-fetch/svelte · @tanstack/svelte-query
SvelteKit’s event.fetch is perfect inside load. The browser still needs a typed client for Query, mutations, and SSE with Authorization.
tanstack-fetch/svelte uses setFetchClient in a layout, useFetch in components, and useSse stores ($data, $status). Kit SSR gets ssr-forward via the migrate scaffold’s api.server.ts.
This post is the deep dive: architecture, side-by-side code, graphs, migrate path, SSE, tRPC, and when to keep the default stack client.
TL;DR
| If you need… | Prefer |
|---|---|
| Browser Query + stores | tanstack-fetch/svelte |
Kit load with cookies | api.server.ts + ssr-forward |
| SSE stores | useSse |
load-only pages, no client cache | event.fetch is fine |
| Share client with React monorepo | same createFetch |
npm install tanstack-fetch svelte @tanstack/svelte-queryMental model
Split server (+page.server.ts / api.server.ts) from browser (setFetchClient in +layout.svelte). Do not open SSE in server load.
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
event.fetch only (glue when client needs cache)
// +page.server.ts
export const load = async ({ fetch }) => {
const users = await fetch('/api/users').then((r) => r.json())
return { users }
}Hydrating into Svelte Query later means a second client and duplicated types.
setFetchClient + Svelte Query
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { createFetch } from 'tanstack-fetch'
import { setFetchClient } from 'tanstack-fetch/svelte'
const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
getToken: () => localStorage.getItem('access_token'),
})
setFetchClient({ client: api })
</script>
<slot /><script lang="ts">
import { useFetch } from 'tanstack-fetch/svelte'
import { createQuery } from '@tanstack/svelte-query'
import { isFetchError } from 'tanstack-fetch'
const api = useFetch()
const users = createQuery({
queryKey: ['users'],
queryFn: ({ signal }) => api.get<User[]>('/users', { signal }),
})
</script>
{#if $users.isPending}
<p>Loading…</p>
{:else if $users.isError}
<p>{isFetchError($users.error) ? $users.error.status : 'Error'}</p>
{:else}
<ul>
{#each $users.data ?? [] as user}
<li>{user.name}</li>
{/each}
</ul>
{/if}Bootstrap / wiring
SvelteKit SSR scaffold:
npx tanstack-fetch migrate --from fetch --framework sveltekit --writeCreates src/lib/api.ts (browser) and src/lib/api.server.ts (ssr-forward + cookies) for load functions.
Comparison graph: where glue accumulates
Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.
tanstack-fetchtypical event.fetch path
Feature matrix
| Capability | tanstack-fetch | event.fetch / raw fetch |
|---|---|---|
| setFetchClient / useFetch | Yes | DIY |
| useSse stores | Yes | DIY |
| Kit SSR helper | migrate api.server | Manual |
| Svelte Query fit | Native | Adapt |
| Typed errors | FetchError | DIY |
| Refresh plugin | Yes | DIY |
SSE in +page.server | No — browser only | N/A |
Auth, status handlers, refresh
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'
let expiresAt = 0
const api = createFetch({
baseUrl: '/api',
getToken: () => localStorage.getItem('access_token'),
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
const body = await api.post<{ accessToken: string; expiresIn: number }>('/auth/refresh', {
interceptors: { eject: ['refresh-token', 'auth'] },
})
localStorage.setItem('access_token', body.accessToken)
expiresAt = Date.now() + body.expiresIn * 1000
},
before: { getExpiresAt: () => expiresAt, skewMs: 60_000 },
after: { enabled: true },
}),
)The plugin keeps one in-flight refresh and retries the failed call — the usual interceptor queue, without the queue code.
Mutations
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
import { useFetch } from 'tanstack-fetch/svelte'
const api = useFetch()
const queryClient = useQueryClient()
const createUser = createMutation({
mutationFn: (input: CreateUserInput) => api.post<User>('/users', { body: input }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})Live data: SSE
Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.
<script lang="ts">
import { createFetch } from 'tanstack-fetch/sse'
import { useSse } from 'tanstack-fetch/svelte'
const api = createFetch({ plugins: ['sse-resume'] })
const { data, status, error, close } = useSse<OrderEvent>('/orders/stream', { client: api })
</script>
{#if $status === 'error'}
<p>{$error?.message}</p>
{:else}
<p>{$status}</p>
<pre>{JSON.stringify($data, null, 2)}</pre>
<button type="button" on:click={close}>Stop</button>
{/if}Status: connecting | connected | disconnected | error.
tRPC over the same client
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { createTRPCFetch } from 'tanstack-fetch/trpc'
import { createQuery } from '@tanstack/svelte-query'
import { api } from './api'
import type { AppRouter } from './server'
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', fetch: createTRPCFetch(api) })],
})
export const postsQuery = () =>
createQuery({
queryKey: ['posts'],
queryFn: ({ signal }) => trpc.post.list.query(undefined, { signal }),
})Migrate
npx tanstack-fetch migrate --from fetch --framework svelte --write
npx tanstack-fetch migrate --from fetch --framework sveltekit --write
npx tanstack-fetch doctor --dir ./srcSuggested folder layout
src/
lib/api.ts
lib/api.server.ts # Kit SSR
routes/+layout.svelte # setFetchClient
routes/+page.svelte
lib/queries/users.tsDecision tree
- Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → event.fetch may stay
- Yes → start with
- Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for simple REST
- Yes →
- Do other frameworks share this backend client?
- Yes → one
createFetchacross packages - No → still fine; adapters are optional
- Yes → one
Checklist
- [ ]
setFetchClientin root layout - [ ] Kit:
api.server.tsfor load cookies - [ ] Forward
signalin queryFns - [ ] SSE only in browser components
- [ ] Doctor clean
Try it
npm install tanstack-fetch svelte @tanstack/svelte-query
npx tanstack-fetch doctor --dir ./srcRelated
Series
Same deep-dive format for every stack:
