Query Advanced
Server rendering
A SvelteKit page renders its rows on the server and hydrates without a second request. One client per request on the server, one client in the browser, both on the load's fetch.
Markdown version of this page, for assistants (Markdown for AI)
Note ./ssr is experimental: the API can still change in a minor release.
Stability per entry point: Entry points.
On this page
Client per request
// src/routes/+layout.ts — one client per request on the server, one client in the browser
import { browser } from '$app/environment';
import { invalidate } from '$app/navigation';
import type { Load } from '@sveltejs/kit';
import { createClient, type Client, type ClientInit } from '@svelte-lean/query';
import { http } from '@svelte-lean/query/http';
import { kitInvalidate } from '@svelte-lean/query/ssr';
let browserClient: Client | undefined;
// The load's `fetch` on both sides: in the browser SvelteKit replays the responses it inlined.
const init = (fetch: typeof globalThis.fetch): ClientInit => ({
transport: http({ base: '/', fetch }),
modules: browser ? [kitInvalidate({ invalidate })] : [] // tags → depends('slq:<Tag>')
});
export const load: Load = ({ fetch }) => ({
client: browser ? (browserClient ??= createClient(init(fetch))) : createClient(init(fetch))
});<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
import { setClient, type Client } from '@svelte-lean/query';
let { data, children }: { data: { client: Client }; children: Snippet } = $props();
// svelte-ignore state_referenced_locally (the client is set once, at initialisation)
setClient(data.client); // this request's components on the server; the realm in the browser
</script>
{@render children()}Never create a client at module level on the server: it would be one cache for every request and
every user. On the server getClient() outside a component throws; pass { client } instead.
Loads that fill the cache
// src/routes/members/+page.ts — fill the cache before the page renders
import type { Load } from '@sveltejs/kit';
import { listMembers } from '$api/members';
import { paramsFrom } from './params';
export const load: Load = async ({ parent, url, depends }) => {
const { client } = await parent();
depends('slq:Members'); // re-run when a write invalidates the Members tag (kitInvalidate)
await listMembers.fetch(paramsFrom(url), { client });
};<!-- src/routes/members/+page.svelte — the same createQuery as without SSR -->
<script lang="ts">
import { page } from '$app/state';
import { createQuery } from '@svelte-lean/query';
import { listMembers } from '$api/members';
import { paramsFrom } from './params';
const members = createQuery(listMembers, () => paramsFrom(page.url));
</script>
<ul>
{#each members.data?.rows ?? [] as member (member.id)}<li>{member.name}</li>{/each}
</ul>// src/routes/members/params.ts — the URL is the source of the filters on both sides
import type { MemberFilters } from '$api/types';
export const paramsFrom = (url: URL): MemberFilters => ({
search: url.searchParams.get('search') ?? undefined,
team: url.searchParams.get('team') ?? undefined
});On the server the entry is in the request's client before the page renders, so the HTML holds
the rows. In the browser the same load runs during hydration, the load's fetch replays the inlined response, and the entry is committed before the components render: no second request,
and the hydrated markup equals the server's.
// src/hooks.server.ts — a universal load may read only the headers SvelteKit serializes
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = ({ event, resolve }) =>
resolve(event, { filterSerializedResponseHeaders: (name) => name === 'content-type' });A universal load may only read the response headers SvelteKit serializes; http() reads content-type.
Freshness
Give server-rendered endpoints a fresh window, or the browser revalidates every
entry at mount. A revalidation after hydration is refreshing, never a skeleton.
Server-only loads
// src/routes/reports/+page.server.ts — a server-only load moves the cache by key
import type { ServerLoad } from '@sveltejs/kit';
import { createClient } from '@svelte-lean/query';
import { http } from '@svelte-lean/query/http';
import { dehydrate } from '@svelte-lean/query/ssr';
import { listMembers } from '$api/members';
export const load: ServerLoad = async ({ fetch }) => {
const client = createClient({ transport: http({ base: '/', fetch }) }); // this request only
await listMembers.fetch({ team: 'Engineering' }, { client });
return { slq: dehydrate(client, { endpoints: [listMembers] }) };
};<!-- src/routes/reports/+page.svelte -->
<script lang="ts">
import { createQuery, getClient } from '@svelte-lean/query';
import { hydrate, type Dehydrated } from '@svelte-lean/query/ssr';
import { listMembers } from '$api/members';
let { data }: { data: { slq: Dehydrated } } = $props();
// svelte-ignore state_referenced_locally (the records are applied once)
hydrate(getClient(), data.slq); // never replaces a newer value; `fresh` counts from the server fetch
const members = createQuery(listMembers, () => ({ team: 'Engineering' }));
</script>
<p>{members.data?.total ?? 0} engineers</p>- Records are matched by key: scope, endpoint id and canonical params, never a digest.
- A record never replaces a newer value;
freshcounts from the server's fetch. - The browser's
scope()must produce the server's scope string, or nothing matches and the browser fetches. - Records no entry consumed are dropped at the first client-side navigation.
dehydratereturns an object, never a string; embedding it yourself means escaping it.
Invalidation and loads
With kitInvalidate({ invalidate }) (in the root load above), every invalidated
tag re-runs the loads that declared depends('slq:<Tag>'); an id tag also
calls slq:<Tag>:<id>. A save that invalidates Members re-runs the members load. The package never imports $app/*; the application passes
SvelteKit's invalidate.
Functions instead of HTTP
// Endpoints answered by functions (a SvelteKit remote query, an RPC call), the rest over HTTP
import { createClient } from '@svelte-lean/query';
import { http } from '@svelte-lean/query/http';
import { source, sources } from '@svelte-lean/query/ssr';
import { listMarkets } from '$api/markets';
import { getMarkets } from './markets-source';
createClient({
transport: sources([source(listMarkets, () => getMarkets())], http({ base: '/' }))
});sources() is a transport that answers the listed endpoints with functions (a remote query,
a database read) and everything else with the fallback. Tags, freshness, access and the cache work
the same.
Paths in short
| Where the data is read | Call | In the browser |
|---|---|---|
| Universal load (+page.ts) | ep.fetch(params, { client }) | SvelteKit inlines the response and replays it in the browser |
| Server-only load (+page.server.ts) | return { slq: dehydrate(client) } | hydrate(getClient(), data.slq) in the page |
| A function instead of HTTP | sources([source(ep, run)], http(…)) | the cache policy stays the same |
On the server
| Concern | Server |
|---|---|
| Timers, polling, retries | never armed; a failed load fetch rejects at once, the browser retries |
| Environment listeners | never attached |
| createQuery | reads what the request’s client holds; never fetches |
| A binding with no entry | renders pending; the browser fetches after hydration |
| Modules persist, tabs, live, devtools | inert |
| Memory | nothing references the client after the request; dispose() is optional |