Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/svelte-db-ssr-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@tanstack/svelte-db': minor
---

Add SSR and hydration support for SvelteKit

New APIs for server-side rendering:

- `createServerContext()` - creates context to collect prefetched queries
- `prefetchLiveQuery()` - prefetches data server-side without starting sync
- `dehydrate()` - serializes context for JSON transport
- `HydrationBoundary` - component that provides hydration context to children

The `useLiveQuery` hook now supports hydration:

- Pass an `id` in the config object to match with server-prefetched data
- Hydrated data is returned immediately while the collection syncs in background
- `isReady` returns `true` when using hydrated data

Also adds subpath exports (`@tanstack/svelte-db/server`, `@tanstack/svelte-db/hydration`) for better tree-shaking.
8 changes: 8 additions & 0 deletions packages/svelte-db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"default": "./dist/server.js"
},
"./hydration": {
"types": "./dist/hydration.svelte.d.ts",
"svelte": "./dist/hydration.svelte.js"
}
},
"sideEffects": [
Expand Down
41 changes: 41 additions & 0 deletions packages/svelte-db/src/HydrationBoundary.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script lang="ts">
import { setHydrationContext } from "./hydration.svelte"
import type { DehydratedState } from "./server"
import type { Snippet } from "svelte"

interface Props {
/**
* The dehydrated state from the server, typically from `dehydrate(serverContext)`
*/
state: DehydratedState | undefined
/**
* Child content to render
*/
children: Snippet
}

let { state, children }: Props = $props()

// Set the hydration context so child components can access hydrated data
setHydrationContext(state)
</script>

<!--
HydrationBoundary component that provides hydrated query data to child components.

This component should wrap your application or page component in SSR environments.
It makes the prefetched query data available to useLiveQuery hooks via Svelte context.

@example
```svelte
<script lang="ts">
import { HydrationBoundary } from '@tanstack/svelte-db'
let { data } = $props()
</script>

<HydrationBoundary state={data.dehydratedState}>
<TodoList />
</HydrationBoundary>
```
-->
{@render children()}
52 changes: 52 additions & 0 deletions packages/svelte-db/src/hydration.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { getContext, setContext } from 'svelte'
import type { DehydratedState } from './server'

/**
* Context key for hydration state
* @internal
*/
const HYDRATION_KEY = Symbol(`tanstack-db-hydration`)

/**
* Set the hydration context with dehydrated state from the server
* This should be called in a parent component to make hydrated data available to child components.
*
* @param state - The dehydrated state from the server, or undefined if no SSR data
* @internal
*/
export function setHydrationContext(state: DehydratedState | undefined): void {
setContext(HYDRATION_KEY, state)
}

/**
* Get the hydration context containing dehydrated state
* Returns undefined if no hydration context has been set or if called outside a component.
*
* @returns The dehydrated state, or undefined if not in a hydration context
* @internal
*/
export function getHydrationContext(): DehydratedState | undefined {
try {
return getContext<DehydratedState | undefined>(HYDRATION_KEY)
} catch {
// getContext throws when called outside of component initialization
// In that case, we simply return undefined (no hydration data available)
return undefined
}
}

/**
* Hook to access hydrated data for a specific query by ID
*
* @param id - The query ID to look up
* @returns The hydrated data for the query, or undefined if not found
* @internal
*/
export function useHydratedQuery<T = any>(id: string): T | undefined {
const state = getHydrationContext()

if (!state) return undefined

const query = state.queries.find((q) => q.id === id)
return query?.data as T | undefined
}
15 changes: 15 additions & 0 deletions packages/svelte-db/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
// Re-export all public APIs
export * from './useLiveQuery.svelte.js'

// SSR/Hydration exports
export {
createServerContext,
prefetchLiveQuery,
dehydrate,
type ServerContext,
type DehydratedState,
type DehydratedQuery,
type PrefetchLiveQueryOptions,
} from './server.js'

export { setHydrationContext, getHydrationContext } from './hydration.svelte.js'

export { default as HydrationBoundary } from './HydrationBoundary.svelte'

// Re-export everything from @tanstack/db
export * from '@tanstack/db'

Expand Down
140 changes: 140 additions & 0 deletions packages/svelte-db/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { createLiveQueryCollection } from '@tanstack/db'
import type {
Context,
InitialQueryBuilder,
LiveQueryCollectionConfig,
QueryBuilder,
} from '@tanstack/db'

/**
* Server context for managing live query prefetching and dehydration
*/
export interface ServerContext {
queries: Map<string, DehydratedQuery>
}

/**
* Dehydrated query result that can be serialized and sent to the client
*/
export interface DehydratedQuery<T = any> {
id: string
data: T
timestamp: number
}

/**
* Dehydrated state containing all prefetched queries
*/
export interface DehydratedState {
queries: Array<DehydratedQuery>
}

/**
* Options for prefetching a live query
*/
export interface PrefetchLiveQueryOptions<TContext extends Context> {
/**
* Unique identifier for this query. Required for hydration to work.
* Must match the id used in the client-side useLiveQuery call.
*/
id: string

/**
* The query to execute
*/
query:
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
| QueryBuilder<TContext>

/**
* Optional transform function to apply to the query results before dehydration.
* Useful for serialization (e.g., converting Date objects to ISO strings).
* Should return an array of rows; non-arrays are normalized to a single-element array.
*
* @example
* ```ts
* transform: (rows) => rows.map(row => ({
* ...row,
* createdAt: row.createdAt.toISOString()
* }))
* ```
*/
transform?: (rows: Array<any>) => Array<any> | any
}

/**
* Create a new server context for managing queries during SSR
*/
export function createServerContext(): ServerContext {
return {
queries: new Map(),
}
}

/**
* Prefetch a live query on the server and store the result in the server context
*
* @example
* ```ts
* // In +page.server.ts
* const serverContext = createServerContext()
*
* await prefetchLiveQuery(serverContext, {
* id: 'todos',
* query: (q) => q.from({ todos: todosCollection })
* })
*
* return { dehydratedState: dehydrate(serverContext) }
* ```
*/
export async function prefetchLiveQuery<TContext extends Context>(
serverContext: ServerContext,
options: PrefetchLiveQueryOptions<TContext>,
): Promise<void> {
const { id, query, transform } = options

// Create a temporary collection for this query
const config: LiveQueryCollectionConfig<TContext> = {
id,
query,
startSync: false, // Don't auto-start, we'll preload manually
}

const collection = createLiveQueryCollection(config)

try {
// Wait for the collection to be ready with data
const base = await collection.toArrayWhenReady()

// Apply optional transform (e.g., for serialization)
const out = transform ? transform(base as Array<any>) : base
// Normalize to array (defensive)
const data = Array.isArray(out) ? out : [out]

// Store in server context
serverContext.queries.set(id, {
id,
data,
timestamp: Date.now(),
})
} finally {
// Clean up the collection
await collection.cleanup()
}
}

/**
* Serialize the server context into a dehydrated state that can be sent to the client
*
* @example
* ```ts
* // In +page.server.ts
* const dehydratedState = dehydrate(serverContext)
* return { dehydratedState }
* ```
*/
export function dehydrate(serverContext: ServerContext): DehydratedState {
return {
queries: Array.from(serverContext.queries.values()),
}
}
Loading
Loading