feat: add SSR integration for TanStack Query v6

- Create +layout.ts to initialize QueryClient for SSR support
- Update +layout.svelte to receive QueryClient from load function
- Add SSR utilities (withInitialData, prefetchQuery, prefetchInfiniteQuery)
- Add documentation for SSR integration patterns

This enables two SSR patterns:
1. initialData pattern for pages using +page.server.ts
2. prefetchQuery pattern for pages using +page.ts

Phase 4 of TanStack Query integration.

Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
Devin AI 2025-11-29 07:56:38 +00:00
parent be24a84e19
commit e1c330f376
4 changed files with 343 additions and 6 deletions

119
src/lib/query/README.md Normal file
View file

@ -0,0 +1,119 @@
# TanStack Query SSR Integration
This directory contains utilities for integrating TanStack Query v6 with SvelteKit's server-side rendering.
## Architecture Overview
The project uses a hybrid approach for SSR:
1. **QueryClient in Layout**: The `QueryClient` is created in `+layout.ts` and passed to `+layout.svelte` via the load function. This enables prefetching in child page load functions.
2. **Server Data with initialData**: Pages that use `+page.server.ts` can pass server-fetched data as `initialData` to TanStack Query using the `withInitialData()` helper.
3. **Prefetching in +page.ts**: Pages that use `+page.ts` (universal load functions) can use `prefetchQuery()` to populate the QueryClient cache before rendering.
## Usage Examples
### Pattern 1: Using Server Data with initialData
For pages that already fetch data in `+page.server.ts`:
```svelte
<!-- +page.svelte -->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import { withInitialData } from '$lib/query/ssr'
import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>()
// Use server-fetched party as initial data
// The query won't refetch until the data becomes stale
const party = createQuery(() => ({
...partyQueries.byShortcode(data.party?.shortcode ?? ''),
...withInitialData(data.party),
enabled: !!data.party?.shortcode
}))
</script>
{#if $party.data}
<h1>{$party.data.name}</h1>
{/if}
```
### Pattern 2: Prefetching in Universal Load Functions
For pages that can use `+page.ts` (not server-only):
```typescript
// +page.ts
import type { PageLoad } from './$types'
import { prefetchQuery } from '$lib/query/ssr'
import { partyQueries } from '$lib/api/queries/party.queries'
export const load: PageLoad = async ({ parent, params }) => {
const { queryClient } = await parent()
// Prefetch party data into the cache
await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
// No need to return data - it's already in the QueryClient cache
return { shortcode: params.id }
}
```
```svelte
<!-- +page.svelte -->
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { partyQueries } from '$lib/api/queries/party.queries'
import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>()
// Data is already in cache from prefetch - no loading state on initial render
const party = createQuery(() => partyQueries.byShortcode(data.shortcode))
</script>
{#if $party.data}
<h1>{$party.data.name}</h1>
{/if}
```
### Pattern 3: Infinite Queries with Prefetching
For paginated data:
```typescript
// +page.ts
import type { PageLoad } from './$types'
import { prefetchInfiniteQuery } from '$lib/query/ssr'
import { partyQueries } from '$lib/api/queries/party.queries'
export const load: PageLoad = async ({ parent }) => {
const { queryClient } = await parent()
// Prefetch first page of parties
await prefetchInfiniteQuery(queryClient, partyQueries.list())
}
```
## Migration Guide
### From Server-Only to TanStack Query
1. **Keep existing +page.server.ts** - No changes needed to server load functions
2. **Add TanStack Query to component** - Use `createQuery` with `withInitialData`
3. **Benefit from caching** - Subsequent navigations use cached data
### From Custom Resources to TanStack Query
1. **Replace resource imports** with query/mutation imports
2. **Use createQuery** instead of resource state
3. **Use mutations** for CRUD operations with automatic cache invalidation
## Files
- `queryClient.ts` - QueryClient factory (legacy, kept for reference)
- `ssr.ts` - SSR utilities (withInitialData, prefetchQuery, etc.)

186
src/lib/query/ssr.ts Normal file
View file

@ -0,0 +1,186 @@
/**
* SSR Utilities for TanStack Query
*
* Provides utilities for integrating server-side data fetching with TanStack Query.
* These utilities support the initialData pattern for pages that use +page.server.ts
* load functions.
*
* @module query/ssr
*/
import type { QueryClient } from '@tanstack/svelte-query'
/**
* Options for creating a query with initial data from SSR
*/
export interface InitialDataOptions<TData> {
/**
* The data fetched on the server to use as initial data
*/
initialData: TData | undefined | null
/**
* Optional timestamp when the data was fetched on the server.
* If not provided, defaults to 0 (will be considered stale immediately).
* Use Date.now() on the server to get accurate timestamps.
*/
initialDataUpdatedAt?: number
}
/**
* Creates query options with initial data from server-side rendering.
*
* Use this helper when you have data fetched in a +page.server.ts load function
* and want to use it as initial data for a TanStack Query.
*
* @example
* ```svelte
* <script lang="ts">
* import { createQuery } from '@tanstack/svelte-query'
* import { partyQueries } from '$lib/api/queries/party.queries'
* import { withInitialData } from '$lib/query/ssr'
* import type { PageData } from './$types'
*
* let { data } = $props<{ data: PageData }>()
*
* // Use server-fetched party as initial data
* const party = createQuery(() => ({
* ...partyQueries.byShortcode(data.party?.shortcode ?? ''),
* ...withInitialData(data.party)
* }))
* </script>
* ```
*
* @param initialData - The data fetched on the server
* @param updatedAt - Optional timestamp when data was fetched (defaults to 0)
* @returns Query options object with initialData and initialDataUpdatedAt
*/
export function withInitialData<TData>(
initialData: TData | undefined | null,
updatedAt?: number
): InitialDataOptions<TData> {
return {
initialData: initialData ?? undefined,
initialDataUpdatedAt: updatedAt ?? 0
}
}
/**
* Prefetches a query on the server and returns the data.
*
* Use this in +page.ts load functions when you want to prefetch data
* into the QueryClient cache. This is the recommended approach for
* pages that don't use +page.server.ts.
*
* Note: This will NOT work with +page.server.ts load functions.
* Use withInitialData() instead for server-only load functions.
*
* @example
* ```typescript
* // +page.ts
* import type { PageLoad } from './$types'
* import { prefetchQuery } from '$lib/query/ssr'
* import { partyQueries } from '$lib/api/queries/party.queries'
*
* export const load: PageLoad = async ({ parent, params }) => {
* const { queryClient } = await parent()
*
* await prefetchQuery(queryClient, partyQueries.byShortcode(params.id))
*
* // No need to return data - it's in the cache
* }
* ```
*
* @param queryClient - The QueryClient instance from parent layout
* @param options - Query options from a query factory
*/
export async function prefetchQuery<TData>(
queryClient: QueryClient,
options: {
queryKey: readonly unknown[]
queryFn: () => Promise<TData>
staleTime?: number
gcTime?: number
}
): Promise<void> {
await queryClient.prefetchQuery({
queryKey: options.queryKey,
queryFn: options.queryFn,
staleTime: options.staleTime,
gcTime: options.gcTime
})
}
/**
* Prefetches an infinite query on the server.
*
* Use this in +page.ts load functions when you want to prefetch
* paginated data into the QueryClient cache.
*
* @example
* ```typescript
* // +page.ts
* import type { PageLoad } from './$types'
* import { prefetchInfiniteQuery } from '$lib/query/ssr'
* import { partyQueries } from '$lib/api/queries/party.queries'
*
* export const load: PageLoad = async ({ parent }) => {
* const { queryClient } = await parent()
*
* await prefetchInfiniteQuery(queryClient, partyQueries.list())
* }
* ```
*
* @param queryClient - The QueryClient instance from parent layout
* @param options - Infinite query options from a query factory
*/
export async function prefetchInfiniteQuery<TData>(
queryClient: QueryClient,
options: {
queryKey: readonly unknown[]
queryFn: (context: { pageParam: number }) => Promise<TData>
initialPageParam: number
staleTime?: number
gcTime?: number
}
): Promise<void> {
await queryClient.prefetchInfiniteQuery({
queryKey: options.queryKey,
queryFn: options.queryFn,
initialPageParam: options.initialPageParam,
staleTime: options.staleTime,
gcTime: options.gcTime
})
}
/**
* Sets query data directly in the cache.
*
* Use this when you have data from a server load function and want
* to populate the QueryClient cache directly. This is useful when
* migrating from server-only load functions to TanStack Query.
*
* @example
* ```typescript
* // In a component or effect
* import { useQueryClient } from '@tanstack/svelte-query'
* import { setQueryData } from '$lib/query/ssr'
* import { partyKeys } from '$lib/api/queries/party.queries'
*
* const queryClient = useQueryClient()
*
* // Populate cache with server data
* setQueryData(queryClient, partyKeys.detail(shortcode), serverParty)
* ```
*
* @param queryClient - The QueryClient instance
* @param queryKey - The query key to set data for
* @param data - The data to set in the cache
*/
export function setQueryData<TData>(
queryClient: QueryClient,
queryKey: readonly unknown[],
data: TData
): void {
queryClient.setQueryData(queryKey, data)
}

View file

@ -11,14 +11,12 @@
import { authStore } from '$lib/stores/auth.store' import { authStore } from '$lib/stores/auth.store'
import { browser } from '$app/environment' import { browser } from '$app/environment'
import { QueryClientProvider } from '@tanstack/svelte-query' import { QueryClientProvider } from '@tanstack/svelte-query'
import { createQueryClient } from '$lib/query/queryClient' import type { LayoutData } from './$types'
const queryClient = createQueryClient()
// Get `data` and `children` from the router via $props() // Get `data` and `children` from the router via $props()
// Use a more flexible type that allows additional properties from child pages // QueryClient is now created in +layout.ts for SSR support
const { data, children } = $props<{ const { data, children } = $props<{
data: any // Allow any data to pass through from child pages data: LayoutData & { [key: string]: any } // Allow any data to pass through from child pages
children: () => any children: () => any
}>() }>()
@ -110,7 +108,7 @@
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={data.queryClient}>
<Tooltip.Provider> <Tooltip.Provider>
<div class="app-container" class:sidebar-open={sidebar.isOpen}> <div class="app-container" class:sidebar-open={sidebar.isOpen}>
<div class="main-pane"> <div class="main-pane">

34
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Root Layout Load Function
*
* Creates a QueryClient instance for SSR support with TanStack Query v6.
* The QueryClient is created here so it can be used for prefetching in
* child page load functions.
*
* @module routes/+layout
*/
import type { LayoutLoad } from './$types'
import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query'
export const load: LayoutLoad = async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Disable queries on server - they will be prefetched explicitly
enabled: browser,
// Cache data for 5 minutes before considering it stale
staleTime: 1000 * 60 * 5,
// Keep unused data in cache for 30 minutes
gcTime: 1000 * 60 * 30,
// Retry failed requests twice
retry: 2,
// Don't refetch on window focus by default
refetchOnWindowFocus: false
}
}
})
return { queryClient }
}