<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, TanStack Query, and TanStack DB
- [Inspired by](/inspired-by): the libraries and ideas rxfy grew out of
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully synced
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Create Store](/getting-started/create-store): Normalized reactive state in a client-only app
- [Add SSR](/getting-started/add-ssr): Render the first paint on the server, hydrate with no refetch
- [Add Sync Client](/getting-started/add-sync-client): The full stack: the server publishes, the client syncs
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [Observables](/core-concepts/observables): A value that changes over time, that you can subscribe to
- [Normalization](/core-concepts/normalization): Store each entity once, reference it by id
- [Late Unwrapping](/core-concepts/late-unwrapping): Unwrap async state at the leaf, not the trunk
- [Server-Side Rendering](/core-concepts/ssr): Dehydrate on the server, hydrate with no refetch
- [rxfy](/rxfy): The core package: atoms, lenses, models, and states
- [createModel](/rxfy/create-model): Typed entities in normalized storage
- [defineState](/rxfy/define-state): Typed, normalized state shapes with fetching and mutations
- [createAtom](/rxfy/create-atom): A reactive cell with synchronous get, set, and modify
- [createLens](/rxfy/create-lens): A two-way view into part of an Atom
- [React Bindings](/react): Hooks and helpers for using rxfy in React
- [useStateData](/react/use-state-data): Fetch, normalize, and subscribe to a query
- [useStatePagedData](/react/use-state-paged-data): Paginated and infinite-scroll lists
- [useModelStore](/react/use-model-store): Subscribe to one normalized entity by id
- [useAtom](/react/use-atom): Two-way binding for any IAtom
- [Pending](/react/pending): Render pending, rejected, and fulfilled UI for any observable
- [usePending](/react/use-pending): The status value behind Pending
- [useObservable](/react/use-observable): Bind a raw Observable to React
- [Sync Client in React](/react/sync-client): StoreProvider, useSyncClient, and update handles
- [rxfy-client](/framework/client): The framework-agnostic browser sync runtime
- [createSyncClient](/framework/client/create-sync-client): Connect a transport and drive the sync loop
- [readSsrGrants](/framework/client/read-ssr-grants): Lift SSR-embedded channel grants
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish sync updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createSync](/framework/server/create-server): Wire a storage adapter, hub, and secret into a Live object
- [createInMemoryHub](/framework/server/hub): The socket-keyed pub/sub backbone
- [Writes](/framework/server/writes): sync.create / sync.update / sync.delete and touch
- [Storage adapters](/framework/server/storage-adapters): Persist writes with Drizzle or in memory
- [Sync messages](/framework/server/messages): What travels between server and client
- [Grants](/framework/server/grants): The server signs what it serves; the client subscribes with the token
- [rxfy-ws](/framework/ws): The default WebSocket transport
- [createWsServer](/framework/ws/server): Attach a Hub to WebSocket connections
- [createWsClient](/framework/ws/client): The browser transport with reconnect and replay
- [Custom transports](/framework/ws/custom-transport): Bring your own ClientTransport
- [Guides](/guides): Task-focused walkthroughs of common rxfy patterns
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
-->

# Server-Side Rendering \[Dehydrate on the server, hydrate with no refetch]

SSR is on-demand; there is no prefetch API. Components declare their data with
`useStateData` exactly as on the client; on the server (`<StoreProvider ssr>`) a cache
miss suspends until the fetch settles. Results (fulfilled or rejected) are captured in the
registry, serialized into the HTML, and ingested on the client so the first paint is
already fulfilled: no loading flash, no re-fetch, no hydration mismatch.

:::info\[Requirements]
models carry a `name` and states a `key` (stable string identities — both required by
their types), and `fetchFn` must work in both environments (it runs on the server during
SSR and on the client for reloads).

[Plain value fields](/rxfy/define-state#plain-value-fields) (bare Zod schemas in `model`) don't need a model `name` — they travel inside the keyed state's dehydrated value. Keep them JSON-serializable.
:::

:::tip\[Building a synced app?]
SSR also signs the channel **grants** a Sync Client uses to receive pushes. See
[Grants](/framework/server/grants).
:::

## Buffered mode (any Node server)

Wait for every Suspense boundary with `onAllReady`, then send the complete document. This
is the recommended non-Next mode. Here it is wired end to end: a render function that
resolves once every `useStateData` fetch has settled, an HTTP handler that injects the HTML
and the dehydrated snapshot into an `index.html` template, and the client entry that
hydrates it.

:::code-group

```tsx [entry-server.tsx]
import { PassThrough } from "node:stream";
import { StrictMode, Suspense } from "react";
import { renderToPipeableStream } from "react-dom/server";
import { createModelRegistry, dehydrate, hydrationScript } from "rxfy";
import { StoreProvider } from "rxfy-react";
import { App } from "./App";

// Runs on the server per request. Returns the rendered HTML plus the <script>
// that carries the dehydrated store snapshot.
export function render(url: string): Promise<{ html: string; state: string }> {
  const registry = createModelRegistry(); // one registry per request — never shared

  return new Promise((resolve, reject) => {
    const { pipe } = renderToPipeableStream(
      <StrictMode>
        <StoreProvider registry={registry} ssr>
          <Suspense fallback={null}>
            <App url={url} />
          </Suspense>
        </StoreProvider>
      </StrictMode>,
      {
        // Fires once every Suspense boundary has resolved — i.e. every fetch has
        // settled and the registry is fully populated. Buffer the stream to a
        // string, then dehydrate the now-complete registry into the snapshot.
        onAllReady() {
          const sink = new PassThrough();
          let html = "";
          sink.on("data", (chunk: Buffer) => (html += chunk.toString()));
          sink.on("end", () => {
            resolve({ html, state: hydrationScript(dehydrate(registry)) });
          });
          pipe(sink);
        },
        onError: (error) => reject(error instanceof Error ? error : new Error(String(error))),
      },
    );
  });
}
```

```ts [server.ts]
import { readFileSync } from "node:fs";
import { render } from "./entry-server";

// index.html carries two placeholders: <!--app-html--> and <!--app-state-->
const template = readFileSync("./index.html", "utf-8");

// Express / Hono / plain node:http — whatever serves your HTML.
app.get("*", async (req, res) => {
  const { html, state } = await render(req.url);
  const page = template
    .replace("<!--app-html-->", html) // the rendered markup
    .replace("<!--app-state-->", state); // the hydrationScript <script> tag
  res.status(200).setHeader("Content-Type", "text/html").end(page);
});
```

```tsx [client.tsx]
// The injected <script> populated window.__RXFY_SSR__; StoreProvider ingests it,
// so the first paint is already fulfilled — no refetch, no hydration mismatch.
import { hydrateRoot } from "react-dom/client";
import { StoreProvider } from "rxfy-react";
import { App } from "./App";

hydrateRoot(
  document.getElementById("root")!,
  <StoreProvider ssr>
    <App url={window.location.pathname} />
  </StoreProvider>,
);
```

:::

See the [rr7-blog example](/examples#rr7-blog) for this mode wired into a React Router 7
(framework mode) app, with loaders kept for routing only. The
[vite-ssr-pagination example](/examples#vite-ssr-pagination) uses the same single-snapshot
injection, but over a streaming shell (`onShellReady`) on a plain Vite server.

## Streaming mode (Next.js App Router)

`rxfy-react/next` ships `<HydrationStream />`: on each stream flush it emits newly settled
queries and entities as `window.__RXFY_SSR__.push(...)` script tags; the client
`StoreProvider` ingests them, including chunks arriving after hydration starts.

```tsx [app/providers.tsx]
"use client";
import { StoreProvider } from "rxfy-react";
import { HydrationStream } from "rxfy-react/next";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <StoreProvider ssr>
      <HydrationStream />
      {children}
    </StoreProvider>
  );
}
```

## Two-pass mode (strict `renderToString`)

For environments without React stream APIs, `collectStateData` loops render passes until
nothing suspends (each fetch waterfall level costs one extra pass):

```ts
import { renderToString } from "react-dom/server";
import { collectStateData } from "rxfy-react";

const html = await collectStateData(registry, () =>
  renderToString(<StoreProvider registry={registry} ssr><App /></StoreProvider>),
);
const state = hydrationScript(dehydrate(registry));
```

### How it works

`renderToString` is synchronous — it can't wait for a fetch. So `collectStateData` renders
repeatedly, warming the query cache one level at a time (the classic Apollo `getDataFromTree`
pattern): each render starts any missing fetches and suspends; `collectStateData` awaits the
inflight fetches and renders again, until a pass leaves nothing pending. That final HTML is
returned, and `dehydrate(registry)` snapshots the now-warm registry.

A pass only discovers the fetches reachable with the data it already has, so a fetch waterfall
**N** levels deep costs **N+1** full renders; independent queries at one level are found and
awaited together.

**Trade-offs:** works anywhere `renderToString` does (edge runtimes, custom renderers) and is
simple to reason about — but it renders the whole tree N+1 times, flushes nothing until every
fetch has settled, and pays the *sum* of a waterfall's fetch times.

:::tip
If you have `renderToPipeableStream`, prefer [buffered mode](#buffered-mode-any-node-server)
or [streaming mode](#streaming-mode-nextjs-app-router) — both avoid the repeated renders.
:::

## Hydration helpers

The server snapshot is produced and embedded with three helpers from `rxfy`:

```ts
type DehydratedState = {
  queries: Record<string, SerializedWrapped>; // settled query results, by key
  models: Record<string, Record<string, unknown>>; // entities, by model name then id
};

// Snapshot a request's registry: every model store (by name) plus all settled queries.
function dehydrate(registry: IModelRegistry): DehydratedState;

// Serialize a snapshot into a ready-to-inject <script> tag (HTML-safe).
function hydrationScript(state: DehydratedState): string;

// Apply a snapshot into a fresh client registry.
function hydrate(registry: IModelRegistry, state: DehydratedState): void;
```

In the common path you never call `hydrate` directly — the embedded script populates
`window.__RXFY_SSR__` and the client `StoreProvider` ingests it automatically.

Synced apps use a different entry point for the same idea: [`sync.hydration(registry)`](/framework/server/grants)
signs one grant per rendered channel, embeds them alongside the dehydrated data, and returns the
hydration script in one call — the client lifts them with [`readSsrGrants`](/framework/client/read-ssr-grants)
and subscribes, instead of calling `dehydrate` / `hydrationScript` directly.

## Error handling

A `fetchFn` rejection on the server is captured as a serialized rejected entry
(`{ type: "REJECTED", error: { name, message } }`, stack stripped) and hydrates as rejected
state: the server HTML shows your `<Pending rejected>` UI, and the handle's `reload()`
retries client-side with a real fetch.
