sveltelean Query
Version v0.2.0

Client per request

src/routes/+layout.ts
// 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
<!-- 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
// 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
<!-- 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
// 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
// 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
// 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
<!-- 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; fresh counts 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.
  • dehydrate returns 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

src/lib/query.ts
// 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 readCallIn 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 HTTPsources([source(ep, run)], http(…))the cache policy stays the same

On the server

ConcernServer
Timers, polling, retriesnever armed; a failed load fetch rejects at once, the browser retries
Environment listenersnever attached
createQueryreads what the request’s client holds; never fetches
A binding with no entryrenders pending; the browser fetches after hydration
Modules persist, tabs, live, devtoolsinert
Memorynothing references the client after the request; dispose() is optional

Reference