TanStack
Guides

SSR and Hydration

TanStack DB SSR transports the smallest useful snapshot for the work the server performed:

  • Explicitly preloaded collections dehydrate as normalized collection rows.
  • Preloaded or render-discovered live queries dehydrate as ordered query-result snapshots, without serializing all of their source collections.

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.

High-level Summary

The SSR-friendly API adds six concepts:

  • DbClient owns materialized collection instances for one request, browser app, test, or script.
  • collectionOptions(...) creates a stable collection descriptor. Reusable descriptors create fresh adapter config for each DbClient.
  • dbClient.dehydrate(), dbClient.hydrate(state), and dbClient.applyCollectionChunk(chunk) move explicit collection state across the server/client boundary.
  • dbClient.preloadLiveQuery(options) captures only the ordered result of a live query for hydration or streaming.
  • React and Svelte apps use DbProvider so hooks can resolve collection descriptors against the current client.
  • @tanstack/react-router-with-db streams live queries discovered by Suspense during a TanStack Start server render.

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:

tsx
useLiveQuery((q) => q.from({ todos }).where(...), [status])

It still works, but warns in development and will be removed in 1.0. Prefer:

tsx
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.

If the old dependency array tracked a Collection object that you replace while the hook stays mounted, give the replacement a distinct ID. A different source object with the same ID now throws in the derived-identity form. To reuse the ID, unmount the old hook, clean up its source and client scope, then mount the replacement.

Cheat Sheet

TaskBeforeSSR-friendly
Define a collectioncreateCollection(options)collectionOptions(id, factory)
Materialize a collectionmodule-level singletondbClient.collection(todoCollection)
Scope collection statemodule lifetimenew DbClient() per request/browser/test
Provide React contextnone<DbProvider client={dbClient}>
Query from Reactdirect collection instancedescriptor in from, resolved by DbProvider
Mutate from Reactimport singleton collectionuseDbClient().collection(todoCollection)
Server preloadad hoc collection preloadcollection.preload() or dbClient.preloadLiveQuery(...)
Serialize SSR statenoneconst state = dbClient.dehydrate()
Hydrate in browsernonedbClient.hydrate(state) before hooks read it
Apply rows incrementallycustom app statedbClient.applyCollectionChunk(chunk)
Stream render-time resultsnonerouterWithDbClient(router, dbClient)
React query identitydependency arrayderived IR, or queryKey when needed

Building or extending a Query keeps descriptors in its plan without starting their source Collections. The client-aware hook or dbClient.preloadLiveQuery() binds the finished plan when it consumes it. Reuse the original unbound Query across clients; a prepared Query contains Collections from the client that prepared it.

Minimal React Pattern

tsx
import {
  DbClient,
  DbProvider,
  Query,
  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>
)

A descriptor query can also be defined outside React, without a DbClient at definition time:

tsx
const openTodosQuery = new Query()
  .from({ todo: todoCollection })
  .where(({ todo }) => eq(todo.status, 'open'))

function OpenTodos() {
  const { data } = useLiveQuery({ query: openTodosQuery })
  return <span>{data.length} open todos</span>
}

The consuming hook binds this query to its DbProvider client. The same query can be used with dbClient.preloadLiveQuery({ query: openTodosQuery }) on the server, or under another provider with a different client. For that reuse, the descriptor must have a reusable factory. Executing a descriptor query without a client throws when the source Collection is needed.

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:

tsx
const todoCollection = collectionOptions(
  localOnlyCollectionOptions<Todo>({
    id: 'todos',
    getKey: (todo) => todo.id,
  })
)

Within one DbClient, descriptors with the same id resolve to the same collection. Only the first descriptor is materialized. Include every dynamic parameter that changes the collection in its 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.

SSR Flow

The server and browser use the same descriptors, but different DbClient instances.

plaintext
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 result

During 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.

A hydrated query hash identifies a portable result, but the payload does not contain the server's Collection objects. The first browser hook that commits a subscription, or the first browser preloadLiveQuery() call, claims the local source Collections for that reusable result. While the result is pending or successful, later local consumers with the same hash must use those Collection objects in the same query positions. A different source object with the same ID is rejected; give a genuinely different query a distinct queryKey. A newer hydration chunk updates the result without changing its established claim. useLiveSuspenseQuery can preload during render, claiming a source even if React never commits that render. A failed stream releases its claim, so a newer hydrated result can accept the first healthy local source. Once a subscribed live result becomes authoritative, the client consumes the stream; a later observer uses its own source Collection, even under the same hash.

Server

Create a fresh DbClient for each request. Materialize descriptors through that client, preload the data needed for the route, and dehydrate the client.

tsx
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()
          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:

tsx
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.

Use the config object form in both the loader and the hook when they share a preloaded result:

tsx
const openTodos = {
  query: (q: InitialQueryBuilder) =>
    q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.status, 'open')),
}

await dbClient.preloadLiveQuery(openTodos)
// In the component:
const { data } = useLiveQuery(openTodos)

The function form useLiveQuery(openTodos.query) currently derives a different identity from useLiveQuery(openTodos). It will not reuse the loader's result. Sharing the query callback alone is insufficient. To use an explicit queryKey, pass config objects with the same queryKey to both preloadLiveQuery and useLiveQuery. Their sources and result configuration must still describe the same query.

Prepare every server snapshot

An ordinary server hook can render empty/loading state without a preload. To render data, await a matching preloadLiveQuery() or explicitly preload the source Collections. A different query warming the same source does not prepare this query's result for transport. Reading those incidental rows or their readiness during SSR throws an error with the required preparation steps. Normal browser reads and runtime query errors keep their usual behavior.

Normalized source transport carries rows without query readiness. When a server hook reads explicitly prepared sources, it captures the query's render-time rows and status as a query result. Ready prepared queries can therefore finish non-streaming Suspense; loading queries remain loading. Later server writes and browser writes before hydration do not change that initial view. Browser commit starts acquisition and publishes the live status normally. A loading capture does not complete an explicit data preload.

An ordinary hook can render loading before a Suspense sibling completes the same query. Each hook retains its own server-rendered view during hydration. The Router stream forwards a newer result for that query without replacing the ordinary hook's earlier view. A later boundary waits for its missing streamed result instead of substituting browser rows. DbClient retains transported server snapshots until cleanup, so one reader's commit cannot consume another boundary's initial view. New ordinary browser reads still use live results.

Streaming useLiveSuspenseQuery discovers and prepares its result during render, including when another query already warmed the source. Ordinary hooks require explicit preparation to render that data. Opaque SSR queries require a serializable queryKey, including when the source is already warm.

For useLiveInfiniteQuery, preload the initial window plus its one-row peek:

tsx
const orderedTodos = new Query()
  .from({ todo: todoCollection })
  .orderBy(({ todo }) => todo.id)
const pageSize = 20

await dbClient.preloadLiveQuery({
  query: orderedTodos.limit(pageSize + 1).offset(0),
})
// In the component:
const result = useLiveInfiniteQuery(() => orderedTodos, { pageSize })

With an explicit queryKey, the DbClient query hash also includes limit and offset. Use the same initial window in the preload and the infinite hook.

Custom dehydration filters must retain the query result that the markup requires.

Indexes before query compilation

Create manual indexes when initializing each request or browser client, before preloading or rendering queries that use them:

tsx
import { BasicIndex, DbClient, type CollectionOptions } from '@tanstack/db'

function createDbClient(todoCollection: CollectionOptions<{ id: string }>) {
  const client = new DbClient()
  client.collection(todoCollection).createIndex((todo) => todo.id, {
    indexType: BasicIndex,
  })
  return client
}

Collection materialization does not start sync unless its config requests it. An index installed inside the sync callback can arrive after the ordered query has compiled. autoIndex: 'eager' with defaultIndexType: BasicIndex is an alternative when indexes should be created as queries compile. Descriptors currently have no indexes or onCreate option.

Preload lifetime

preloadLiveQuery() has no built-in deadline or AbortSignal option. A source that keeps retrying without becoming ready can keep its preload pending. Apply an application deadline to request work and clean up its request-scoped client when abandoning it; a timer race alone does not stop the underlying sync.

Preloaded results belong to the client. There is no public method to release one preloaded query. cleanup() releases the entire client and its Collections. Avoid accumulating route-specific preloads in a browser client without an application lifetime policy. A separately owned createLiveQueryCollection() can be preloaded and cleaned up explicitly when per-query ownership is needed.

Browser

Hydrate the browser client before rendering components that read from DB.

tsx
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

Svelte resolves descriptors from its own DbProvider and reads hydrated query snapshots synchronously during server rendering:

svelte
<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} hydrating>
  <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.

Set hydrating for each root initialized with Svelte's hydrate(). It preserves that root's server public snapshot even after another root publishes browser rows. Nested providers inherit it unless they specify another value. The provider clears it after mount, so later children read live rows. Omit it for ordinary mount() calls.

Hooks that pass client explicitly also need a marked provider around their hydrated subtree. The client alone cannot identify hydration versus a fresh mount.

Live demo: https://tanstack-db-ssr-demo.netlify.app/ssr-db

Suspense Streaming with TanStack Start

@tanstack/react-router-with-db follows the same integration pattern as @tanstack/react-router-with-query:

tsx
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.

tsx
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.

Suspense Streaming with Next.js

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:

tsx
export default function Page() {
  const dbClient = new DbClient()
  void dbClient.preloadLiveQuery({ query: 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.

Incremental Collection Hydration

Applications can also apply collection rows received through their own stream. Incremental hydration uses the same collection chunk shape as holistic dehydration:

ts
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.

What Gets Serialized

dbClient.dehydrate() can emit two independent snapshot types.

Serialized:

  • explicit collection snapshots: collection id, synced row keys and values, row metadata, and adapter sync metadata from exportSyncMeta
  • live-query snapshots: query hash and ordered result rows; completed explicit preloads are included by default, while framework integrations opt pending promises into streaming

Not serialized:

  • mutation handlers
  • pending optimistic mutations
  • pending subscriptions
  • D2 graphs or compiled pipelines
  • transaction stacks
  • module-level runtime state
  • source collection rows for a query-result snapshot, unless that collection was also explicitly materialized for dehydration

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.

Sync Metadata

Adapters can participate in resumable sync with three optional hooks:

ts
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.

Initial Data

initialData is a startup seed, not a sync-ready signal.

Before adapter sync starts, current DbClient precedence from lowest to highest is:

  1. per-materialization initialData
  2. persisted rows
  3. hydrated rows

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 Query Identity

React hooks derive live query identity from structured query IR by default:

tsx
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:

tsx
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:

  • .fn.where(...)
  • .fn.select(...)
  • .fn.having(...)
  • function values, symbols, class instances, or circular objects captured inside the structured query
  • a render path where derived identity becomes measurably expensive

When structured IR cannot be hashed and the query has no queryKey, a hook that compares identity throws UnhashableQueryIRError: React and Preact hooks, and any hook backed by a DbClient, which matches queries by identity hash. An empty queryKey counts as none. Represent the values that opaque logic captures in queryKey, so the query recompiles when they change.

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:

tsx
useLiveQuery((q) => q.from({ todo: todoCollection }), [status])

They warn in development and will be removed in 1.0. Migrate to the config object form:

tsx
useLiveQuery({
  query: (q) => q.from({ todo: todoCollection }),
})

Add queryKey only if the query uses opaque logic or trips the performance warning.

Migration Guide

1. Create descriptors instead of SSR singletons

For collections that need SSR, replace module-level createCollection(...) with a reusable collectionOptions(...) descriptor.

tsx
// 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.

2. Add a DbClient

Use a new client for every server request and a stable client for each browser app instance.

tsx
const dbClient = new DbClient()

In tests, create a new client per test unless the test is explicitly covering shared state.

3. Wrap React with DbProvider

tsx
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.

4. Use collection hooks for imperative operations

Use descriptors directly in live query sources, and materialize only when you need collection methods:

tsx
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.

5. Replace dependency arrays

Most queries can drop the dependency array entirely:

tsx
// 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:

tsx
useLiveQuery({
  queryKey: [todoCollection.id, 'status-fn', status],
  query: (q) =>
    q
      .from({ todo: todoCollection })
      .fn.where(({ todo }) => todo.status === status),
})

6. Preload and dehydrate on the server

Preload a collection when the browser should receive normalized source rows:

tsx
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:

tsx
const dbClient = new DbClient()
await dbClient.preloadLiveQuery({ query: openTodosQuery })

return {
  dbState: dbClient.dehydrate(),
}

7. Hydrate before client hooks read DB

tsx
<DbProvider client={client}>
  <HydrationBoundary state={loaderData.dbState}>
    <App />
  </HydrationBoundary>
</DbProvider>

Imperative integrations can call client.hydrate(loaderData.dbState) before rendering instead.

Compatibility

No existing public API is removed by this change.

Still supported:

  • createCollection(...)
  • passing collection instances to useLiveQuery(...)
  • useLiveQuery(queryFn, deps)
  • useLiveSuspenseQuery(queryFn, deps)
  • mutation APIs such as insert, update, delete, subscribe, and optimistic mutation helpers

Warnings:

  • React dependency arrays warn in development and will be removed in 1.0.
  • Opaque query IR without queryKey throws UnhashableQueryIRError in a hook that compares identity (React, Preact, or any DbClient-backed hook).
  • Expensive derived identity warns in development and suggests queryKey.

Required for SSR:

  • stable explicit collection ids
  • request-scoped server DbClient
  • browser-scoped client DbClient
  • DbProvider for descriptor resolution in React
  • dehydrate() on the server and hydrate() in the browser

Required for render-time Suspense streaming:

  • routerWithDbClient(router, dbClient)
  • useLiveSuspenseQuery(...) inside a Suspense boundary
  • a stable derived query identity or explicit serializable queryKey

Detailed Changelog

Added

  • DbClient
  • collectionOptions(...)
  • CollectionOptions descriptor type
  • CollectionMaterializeOptions
  • DehydratedDbState
  • DehydratedCollectionChunk
  • DehydratedCollectionRow
  • dbClient.collection(descriptor, options?)
  • dbClient.dehydrate()
  • dbClient.hydrate(state)
  • dbClient.applyCollectionChunk(chunk)
  • dbClient.subscribe(listener)
  • dbClient.createTransaction(config)
  • dbClient.cleanup()
  • React DbProvider
  • React useDbClient()
  • React useOptionalDbClient()
  • React HydrationBoundary
  • React descriptor resolution inside live query builders
  • React derived structured query identity
  • React queryKey escape hatch for opaque or hot-path queries
  • React per-query client override
  • SSR-capable useSyncExternalStore server snapshot support
  • dbClient.preloadLiveQuery(...)
  • Svelte DbProvider, useDbClient(), descriptor resolution, and synchronous server snapshot support
  • TanStack Start and Next.js Playwright SSR E2E coverage
  • @tanstack/react-router-with-db
  • render-time useLiveSuspenseQuery promise streaming

Changed

  • React useLiveQuery({ query }) can use collection descriptors directly in from, join, leftJoin, and unionAll sources when a DbProvider is present.
  • React live query identity is derived from normalized structured IR when no explicit queryKey or legacy dependency array is supplied.
  • Explicit collection preloading serializes normalized collection rows.
  • Live-query preloading and render-time discovery serialize ordered result snapshots without implicitly serializing source collections.
  • Browser observers keep the hydrated result visible while normal source sync starts, then publish one authoritative handoff.
  • Hydration applies rows as committed synced state without invoking mutation handlers or creating optimistic state.
  • Hydration and adapter sync begin in a deterministic order: pending rows and sync metadata are imported before sync starts.
  • DbClient owns collection instances and ambient transaction scope; cleanup releases both.
  • Incremental chunks use the same collection payload shape as full dehydration.
  • Streamed live-query promises resolve to live-query result snapshots.

Deprecated

  • React dependency arrays for useLiveQuery and wrappers that delegate to it. They still work and warn in development. They are planned for removal in 1.0.

Not Changed

  • createCollection(...) remains available.
  • Direct collection runtime APIs remain available.
  • Vue, Solid, and Angular keep their existing dependency/reactivity model until they get their own SSR/client-provider work. Svelte is covered by this change.
  • Query collection queryKey is still TanStack Query's cache key. It is separate from React live query identity.

Validation

The SSR strategy is covered by:

  • core DbClient tests for hydration, streaming chunks, sync metadata, initial data precedence, explicit ids, and no optimistic serialization
  • React tests for DbProvider, descriptor resolution, derived query identity, queryKey, deprecation warnings, SSR result snapshots, and atomic handoff
  • Svelte tests for provider ownership, server snapshot rendering, and browser handoff
  • query adapter tests to ensure Query cache behavior still holds
  • persistence core tests to ensure persisted row behavior remains intact
  • TanStack Start and Next.js Playwright E2Es that verify a Suspense fallback, streamed query result, omitted source-only data, clean hydration, and atomic replacement by browser sync