diff --git a/.changeset/svelte-db-ssr-hydration.md b/.changeset/svelte-db-ssr-hydration.md
new file mode 100644
index 000000000..dd19dddf6
--- /dev/null
+++ b/.changeset/svelte-db-ssr-hydration.md
@@ -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.
diff --git a/packages/svelte-db/package.json b/packages/svelte-db/package.json
index 0c1dd1f1f..d109498b5 100644
--- a/packages/svelte-db/package.json
+++ b/packages/svelte-db/package.json
@@ -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": [
diff --git a/packages/svelte-db/src/HydrationBoundary.svelte b/packages/svelte-db/src/HydrationBoundary.svelte
new file mode 100644
index 000000000..43db65050
--- /dev/null
+++ b/packages/svelte-db/src/HydrationBoundary.svelte
@@ -0,0 +1,41 @@
+
+
+
+{@render children()}
diff --git a/packages/svelte-db/src/hydration.svelte.ts b/packages/svelte-db/src/hydration.svelte.ts
new file mode 100644
index 000000000..ce06c8b1e
--- /dev/null
+++ b/packages/svelte-db/src/hydration.svelte.ts
@@ -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(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(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
+}
diff --git a/packages/svelte-db/src/index.ts b/packages/svelte-db/src/index.ts
index a07f98a64..9d9111f02 100644
--- a/packages/svelte-db/src/index.ts
+++ b/packages/svelte-db/src/index.ts
@@ -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'
diff --git a/packages/svelte-db/src/server.ts b/packages/svelte-db/src/server.ts
new file mode 100644
index 000000000..ed912bc0e
--- /dev/null
+++ b/packages/svelte-db/src/server.ts
@@ -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
+}
+
+/**
+ * Dehydrated query result that can be serialized and sent to the client
+ */
+export interface DehydratedQuery {
+ id: string
+ data: T
+ timestamp: number
+}
+
+/**
+ * Dehydrated state containing all prefetched queries
+ */
+export interface DehydratedState {
+ queries: Array
+}
+
+/**
+ * Options for prefetching a live query
+ */
+export interface PrefetchLiveQueryOptions {
+ /**
+ * 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)
+ | QueryBuilder
+
+ /**
+ * 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) => Array | 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(
+ serverContext: ServerContext,
+ options: PrefetchLiveQueryOptions,
+): Promise {
+ const { id, query, transform } = options
+
+ // Create a temporary collection for this query
+ const config: LiveQueryCollectionConfig = {
+ 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) : 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()),
+ }
+}
diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts
index cd82a59fa..f83b606ba 100644
--- a/packages/svelte-db/src/useLiveQuery.svelte.ts
+++ b/packages/svelte-db/src/useLiveQuery.svelte.ts
@@ -3,6 +3,7 @@ import { untrack } from 'svelte'
// eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308
import { SvelteMap } from 'svelte/reactivity'
import { BaseQueryBuilder, createLiveQueryCollection } from '@tanstack/db'
+import { getHydrationContext, useHydratedQuery } from './hydration.svelte'
import type {
ChangeMessage,
Collection,
@@ -286,6 +287,20 @@ export function useLiveQuery(
configOrQueryOrCollection: any,
deps: Array<() => unknown> = [],
): UseLiveQueryReturn | UseLiveQueryReturnWithCollection {
+ // Extract query ID from config object (not from collections or functions)
+ // Only config objects support id for SSR hydration matching
+ const queryId =
+ typeof configOrQueryOrCollection === `object` &&
+ configOrQueryOrCollection !== null &&
+ `id` in configOrQueryOrCollection &&
+ typeof configOrQueryOrCollection.subscribeChanges !== `function` // Not a collection
+ ? configOrQueryOrCollection.id
+ : undefined
+
+ // Get hydration context and hydrated data
+ const hydrationState = getHydrationContext()
+ const hydratedData = queryId ? useHydratedQuery(queryId) : undefined
+
const collection = $derived.by(() => {
// First check if the original parameter might be a getter
// by seeing if toValue returns something different than the original
@@ -336,6 +351,17 @@ export function useLiveQuery(
startSync: true,
})
} else {
+ // Config object case - check if query returns null/undefined
+ const queryFn = unwrappedParam.query
+ if (typeof queryFn === `function`) {
+ const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder
+ const result = queryFn(queryBuilder)
+ if (result === undefined || result === null) {
+ // Disabled query - return null
+ return null
+ }
+ }
+
return createLiveQueryCollection({
...unwrappedParam,
startSync: true,
@@ -454,11 +480,70 @@ export function useLiveQuery(
}
})
+ // Check if we should use hydrated data
+ // Use hydrated data if:
+ // 1. We have hydrated data
+ // 2. The collection is empty (no data loaded yet)
+ const shouldUseHydratedData = () =>
+ hydratedData !== undefined && internalData.length === 0
+
+ // Dev-mode hint: warn if hydrationState exists (SSR setup) but query has id and no matching data
+ // This catches the case where HydrationBoundary is present but this specific query wasn't prefetched
+ if (
+ process.env.NODE_ENV !== `production` &&
+ hydrationState && // Only warn if we're in an SSR environment with HydrationBoundary
+ queryId &&
+ hydratedData === undefined
+ ) {
+ console.warn(
+ `TanStack DB: no hydrated data found for id "${queryId}" — did you prefetch this query on the server with prefetchLiveQuery()?`,
+ )
+ }
+
return {
get state() {
+ // If using hydrated data, convert to Map
+ if (shouldUseHydratedData()) {
+ const currentCollection = collection
+ const config = currentCollection?.config as
+ | CollectionConfigSingleRowOption
+ | undefined
+ const hydrated = Array.isArray(hydratedData)
+ ? hydratedData
+ : [hydratedData]
+ return new Map(
+ hydrated.map((item, index) => {
+ // Try to use getKey if available, otherwise use index
+ const key =
+ config && typeof config.getKey === `function`
+ ? config.getKey(item)
+ : index
+ return [key, item]
+ }),
+ )
+ }
return state
},
get data() {
+ // If using hydrated data, return it directly
+ if (shouldUseHydratedData()) {
+ const currentCollection = collection
+ if (currentCollection) {
+ const config =
+ currentCollection.config as CollectionConfigSingleRowOption<
+ any,
+ any,
+ any
+ >
+ if (config.singleResult) {
+ return Array.isArray(hydratedData) ? hydratedData[0] : hydratedData
+ }
+ }
+ // Ensure array when singleResult is false
+ return Array.isArray(hydratedData) ? hydratedData : [hydratedData]
+ }
+
+ // Normal case: use collection data
const currentCollection = collection
if (currentCollection) {
const config =
@@ -483,7 +568,10 @@ export function useLiveQuery(
return status === `loading`
},
get isReady() {
- return status === `ready` || status === `disabled`
+ // Consider hydrated data as "ready enough" for UI
+ return (
+ status === `ready` || status === `disabled` || shouldUseHydratedData()
+ )
},
get isIdle() {
return status === `idle`