TanStack DB SSR transports the smallest useful snapshot for the work the server performed:
The browser renders either snapshot immediately, starts its normal collection sync and live-query pipeline, then atomically replaces a live-query snapshot when the browser result becomes authoritative.
The SSR-friendly API adds six concepts:
Existing apps continue to work. createCollection(...) and direct collection instances still exist. The migration is required when you want SSR-safe request isolation, hydration, incremental chunks, Suspense streaming, or the 1.0-ready React hook shape.
The old dependency-array form now warns:
useLiveQuery((q) => q.from({ todos }).where(...), [status])It still works, but warns in development and will be removed in 1.0. Prefer:
useLiveQuery({
query: (q) => q.from({ todos: todoCollection }).where(...),
})React derives live query identity from structured query IR by default. Add queryKey only for opaque functional query logic or for a hot render path where you want to skip derived identity work.
| Task | Before | SSR-friendly |
|---|---|---|
| Define a collection | createCollection(options) | collectionOptions(id, factory) |
| Materialize a collection | module-level singleton | dbClient.collection(todoCollection) |
| Scope collection state | module lifetime | new DbClient() per request/browser/test |
| Provide React context | none | <DbProvider client={dbClient}> |
| Query from React | direct collection instance | descriptor in from, resolved by DbProvider |
| Mutate from React | import singleton collection | useDbClient().collection(todoCollection) |
| Server preload | ad hoc collection preload | collection.preload() or dbClient.preloadLiveQuery(...) |
| Serialize SSR state | none | const state = dbClient.dehydrate() |
| Hydrate in browser | none | dbClient.hydrate(state) before hooks read it |
| Apply rows incrementally | custom app state | dbClient.applyCollectionChunk(chunk) |
| Stream render-time results | none | routerWithDbClient(router, dbClient) |
| React query identity | dependency array | derived IR, or queryKey when needed |
import {
DbClient,
DbProvider,
collectionOptions,
eq,
useDbClient,
useLiveQuery,
} from '@tanstack/react-db'
const todoCollection = collectionOptions('todos', () => ({
id: 'todos',
getKey: (todo: Todo) => todo.id,
sync: {
sync: ({ markReady }) => {
markReady()
},
},
}))
function useTodoCollection() {
return useDbClient().collection(todoCollection)
}
function Todos({ status }: { status: string }) {
const todos = useTodoCollection()
const { data } = useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, status)),
})
return (
<ul>
{data.map((todo) => (
<li
key={todo.id}
onClick={() => todos.update(todo.id, (draft) => {
draft.done = true
})}
>
{todo.title}
</li>
))}
</ul>
)
}
const dbClient = new DbClient()
root.render(
<DbProvider client={dbClient}>
<Todos status="open" />
</DbProvider>
)The factory matters when config contains mutable adapter state or closures. Every DbClient gets a fresh config and collection instance. First-party adapter option creators already attach an equivalent factory, so this is also safe:
const todoCollection = collectionOptions(
localOnlyCollectionOptions<Todo>({
id: 'todos',
getKey: (todo) => todo.id,
})
)A descriptor created from an arbitrary concrete config can be materialized by one DbClient only. Use the explicit factory form for custom adapters and request-scoped dependencies.
The server and browser use the same descriptors, but different DbClient instances.
server request
-> new DbClient()
-> preload an explicit collection or live-query result
-> dbClient.dehydrate()
-> send state through framework loader
browser
-> new DbClient()
-> dbClient.hydrate(loaderState)
-> <DbProvider client={dbClient}>
-> useLiveQuery({ query })
-> start source sync
-> atomically replace any query snapshot with the live resultDuring React hydration, descriptor-backed queries read either hydrated collection rows or their matching query-result snapshot for the first browser render. Adapter sync and queued on-demand loads start when React commits the external-store subscription, so the initial markup still matches the server. The snapshot remains visible while the source is loading. Once the browser live query is ready, DB publishes one handoff from the snapshot to the live result.
Create a fresh DbClient for each request. Materialize descriptors through that client, preload the data needed for the route, and dehydrate the client.
import { DbClient, collectionOptions, eq } from '@tanstack/db'
export const todoCollection = collectionOptions('todos', () => ({
id: 'todos',
getKey: (todo: Todo) => todo.id,
syncMode: 'on-demand',
sync: {
sync: ({ markReady, begin, write, commit }) => {
markReady()
return {
loadSubset: async () => {
const todos = await api.todos.list()
begin({ immediate: true })
for (const todo of todos) {
write({ type: 'insert', value: todo })
}
commit()
return true
},
}
},
},
}))
export async function loadTodosForSsr() {
const dbClient = new DbClient()
const todos = dbClient.collection(todoCollection)
await todos.preload()
return dbClient.dehydrate()
}This explicit collection preload dehydrates normalized source rows. Use it when multiple browser queries need the same source data.
If the source is much larger than the rendered result, preload the query instead:
const dbClient = new DbClient()
await dbClient.preloadLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, 'open'))
.select(({ todo }) => ({ id: todo.id, title: todo.title })),
})
const state = dbClient.dehydrate()This payload contains the projected query result and no source collection rows unless that collection was also materialized explicitly.
Hydrate the browser client before rendering components that read from DB.
import {
DbClient,
DbProvider,
HydrationBoundary,
} from '@tanstack/react-db'
function App({ dehydratedDbState }: { dehydratedDbState: DehydratedDbState }) {
const [dbClient] = React.useState(() => new DbClient())
return (
<DbProvider client={dbClient}>
<HydrationBoundary state={dehydratedDbState}>
<Routes />
</HydrationBoundary>
</DbProvider>
)
}Frameworks differ in how loader data reaches the client, but the DB handoff is the same: DbClient on the server, dehydrate(), then hydrate() into the browser client.
Svelte resolves descriptors from its own DbProvider and reads hydrated query snapshots synchronously during server rendering:
<script lang="ts">
import { DbClient, DbProvider } from '@tanstack/svelte-db'
import Todos from './Todos.svelte'
const client = new DbClient()
client.hydrate(dehydratedDbState)
</script>
<DbProvider {client}>
<Todos />
</DbProvider>Inside Todos.svelte, useLiveQuery({ query }) can use collection descriptors directly. The browser subscription starts source sync and performs the same snapshot-to-live-result handoff as React.
Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db
@tanstack/react-router-with-db follows the same integration pattern as @tanstack/react-router-with-query:
import { DbClient } from '@tanstack/react-db'
import { createRouter } from '@tanstack/react-router'
import { routerWithDbClient } from '@tanstack/react-router-with-db'
export type RouterContext = {
dbClient: DbClient
}
export function getRouter() {
const dbClient = new DbClient()
const router = createRouter({
routeTree,
context: { dbClient },
})
return routerWithDbClient(router, dbClient)
}The adapter adds dbClient to router context, wraps the app in DbProvider, dehydrates critical state, and opens a stream for query results discovered later during rendering.
function RouteComponent() {
return (
<Suspense fallback={<p>Loading todos</p>}>
<TodoList />
</Suspense>
)
}
function TodoList() {
const { data } = useLiveSuspenseQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, 'open')),
})
return data.map((todo) => <Todo key={todo.id} todo={todo} />)
}When TodoList suspends on the server, the adapter streams the pending query promise. That promise resolves to the ordered live-query result snapshot inside the streamed DehydratedDbState. The source collections and D2 graph do not cross the wire. The browser shows the snapshot, starts the source collections and live query normally, then replaces the snapshot when the browser result is ready.
The server and browser must derive the same live-query identity. Structured queries do this automatically. An opaque query must provide a serializable queryKey; render-time streaming throws if it cannot derive an identity.
Next.js App Router can transport the same pending query promise through React Server Components. Start the preload without awaiting it, dehydrate the pending result, and pass that state to a client hydration boundary:
export default function Page() {
const dbClient = new DbClient()
void dbClient.preloadLiveQuery(openTodosQuery)
const state = dbClient.dehydrate({
shouldDehydrateCollection: () => false,
shouldDehydrateLiveQuery: () => true,
})
return (
<DbHydration state={state}>
<Suspense fallback={<p>Loading todos</p>}>
<TodoList />
</Suspense>
</DbHydration>
)
}DbHydration is a client component that creates one browser DbClient, wraps children in DbProvider, and passes state to HydrationBoundary. React streams the promise result into that boundary. The full working integration is in examples/react/next-ssr-e2e.
Applications can also apply collection rows received through their own stream. Incremental hydration uses the same collection chunk shape as holistic dehydration:
dbClient.applyCollectionChunk({
collectionId: 'todos',
rows: [
{
key: 'todo-1',
value: {
id: 'todo-1',
title: 'Streamed row',
status: 'open',
},
metadata: { source: 'stream' },
},
],
syncMeta: { version: 1, cursor: 'abc' },
})If the target collection is already materialized, the rows apply immediately and existing live queries react from collection state. If the collection is not materialized yet, the chunk is stored and applied when that collectionId materializes.
dbClient.dehydrate() can emit two independent snapshot types.
Serialized:
Not serialized:
Choose the payload unit according to what the browser needs. Explicit collection preloading preserves normalized rows for reuse across queries. Live-query preloading avoids shipping a 50-100x larger source when the rendered projection is small. Neither mode serializes executable query state.
Adapters can participate in resumable sync with three optional hooks:
type SyncConfig = {
exportSyncMeta?: () => unknown
importSyncMeta?: (meta: unknown) => void
mergeSyncMeta?: (current: unknown, incoming: unknown) => unknown
}The metadata shape is adapter-owned. Version it inside the adapter payload. If an adapter cannot understand incoming metadata, it should ignore it and restart sync from a safe point.
During hydration, DB imports syncMeta into the materialized collection. If the collection already has current metadata, DB calls mergeSyncMeta(current, incoming) when provided and imports the merged result.
If an adapter does not implement sync metadata hooks, row snapshots still hydrate and the adapter can restart sync normally.
initialData is a startup seed, not a sync-ready signal.
Before adapter sync starts, current DbClient precedence from lowest to highest is:
Fresh adapter sync is authoritative over all three. Hydrated and initial rows are provisional base state, so the adapter's first insert for the same key is reconciled as an update instead of raising a duplicate-key error.
Hydrated rows and initialData never mark adapter sync as ready by themselves. The adapter still owns readiness through its sync lifecycle.
React hooks derive live query identity from structured query IR by default:
function Todos({ status }: { status: string }) {
return useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, status)),
})
}The captured status value is represented in the structured IR, so no dependency array or queryKey is required.
Use queryKey when the query contains opaque runtime logic that DB cannot stably represent:
function SearchTodos({ search }: { search: string }) {
return useLiveQuery({
queryKey: [todoCollection.id, 'search', search],
query: (q) =>
q
.from({ todo: todoCollection })
.fn.where(({ todo }) =>
todo.title.toLowerCase().includes(search.toLowerCase())
),
})
}Common reasons to add queryKey:
Before 1.0, DB warns when structured IR cannot be hashed and preserves the legacy mount-stable identity. The query still works, but captured values inside opaque logic are not reactive unless they are represented in queryKey. In 1.0, an unhashable query without queryKey will throw.
DB also warns once in development if deriving identity becomes expensive enough that an explicit queryKey would be better.
Dependency arrays are accepted for backwards compatibility:
useLiveQuery((q) => q.from({ todo: todoCollection }), [status])They warn in development and will be removed in 1.0. Migrate to the config object form:
useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
})Add queryKey only if the query uses opaque logic or trips the performance warning.
For collections that need SSR, replace module-level createCollection(...) with a reusable collectionOptions(...) descriptor.
// Before
export const todoCollection = createCollection({
id: 'todos',
getKey: (todo) => todo.id,
sync: todoSync,
})
// After
export const todoCollection = collectionOptions('todos', () => ({
id: 'todos',
getKey: (todo: Todo) => todo.id,
sync: createTodoSync(),
}))Put mutable state and closures inside the factory. First-party adapter option creators can also be passed directly because they provide a fresh config factory. Collections that never participate in SSR can keep using createCollection.
Use a new client for every server request and a stable client for each browser app instance.
const dbClient = new DbClient()In tests, create a new client per test unless the test is explicitly covering shared state.
root.render(
<DbProvider client={dbClient}>
<App />
</DbProvider>
)Hooks that resolve collection descriptors need this provider. Without it, DB throws instead of falling back to hidden global state.
Use descriptors directly in live query sources, and materialize only when you need collection methods:
function useTodoCollection() {
return useDbClient().collection(todoCollection)
}
function TodoActions({ id }: { id: string }) {
const todos = useTodoCollection()
return (
<button onClick={() => todos.delete(id)}>
Delete
</button>
)
}This keeps request/client scoping in one place and avoids reintroducing module-level collections.
Most queries can drop the dependency array entirely:
// Before
useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, status)),
[status],
)
// After
useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.status, status)),
})If the query uses opaque functional variants, add queryKey:
useLiveQuery({
queryKey: [todoCollection.id, 'status-fn', status],
query: (q) =>
q
.from({ todo: todoCollection })
.fn.where(({ todo }) => todo.status === status),
})Preload a collection when the browser should receive normalized source rows:
const dbClient = new DbClient()
const todos = dbClient.collection(todoCollection)
await todos.preload()
return {
dbState: dbClient.dehydrate(),
}Preload a live query when the browser only needs the rendered result:
const dbClient = new DbClient()
await dbClient.preloadLiveQuery(openTodosQuery)
return {
dbState: dbClient.dehydrate(),
}<DbProvider client={client}>
<HydrationBoundary state={loaderData.dbState}>
<App />
</HydrationBoundary>
</DbProvider>Imperative integrations can call client.hydrate(loaderData.dbState) before rendering instead.
No existing public API is removed by this change.
Still supported:
Warnings:
Required for SSR:
Required for render-time Suspense streaming:
The SSR strategy is covered by: