# Server rendering

> SvelteKit with ./ssr: one client per request, loads that fill the cache, dehydrate and hydrate, and tags that re-run loads.

Page: https://svelte-lean.vercel.app/query/ssr · Markdown: https://svelte-lean.vercel.app/query/ssr.md · Index: https://svelte-lean.vercel.app/llms.txt

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.

> **Note** `./ssr` is experimental: the API can still change in a minor release. Stability per entry point: [Entry points](https://svelte-lean.vercel.app/query.md#entry-points).

## Client per request

`src/routes/+layout.ts`

```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`

```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`

```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`

```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`

```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`

```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`

```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`

```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`

```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 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 |

## Reference

- [@svelte-lean/query/ssr](https://svelte-lean.vercel.app/query/api/ssr.md)
- [Client setup](https://svelte-lean.vercel.app/query/client.md)
