<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, and TanStack Query
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully live
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Store quickstart](/getting-started/store): Normalized reactive state in a client-only app
- [Framework quickstart](/getting-started/framework): The full stack for a live app
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [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
- [Live client](/react/live-client): Wire a WebSocket transport into StoreProvider
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish live updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createServer](/framework/server/create-server): Wire db, resources, hub, and keyer into a Live object
- [Writes](/framework/server/writes): live.create / live.update / live.delete and touch
- [Live messages](/framework/server/messages): What travels between server and client
- [Grants & live hydration](/framework/server/grants): Hand off an SSR render to a live client
- [createTopicKeyer](/framework/server/create-topic-keyer): Time-windowed HMAC ids for topics
- [createInMemoryHub](/framework/server/hub): The pub/sub backbone
- [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
- [Build a Todo app](/guides/todo-app): A guided, end-to-end first app
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
- [Live blog](/guides/live-blog): Build a real-time blog: SSR, then live patches and stale badges
-->

# Live blog \[Build a real-time blog: SSR, then live patches and stale badges]

This tutorial walks through `examples/vite-blog-framework` in the order you would build it: define
the schema and resources, stand up a live server, wire WebSockets, write through the API, SSR with
grants, and finally hydrate on the client. By the end you have a blog where edits propagate to every
open tab in real time and new or deleted posts show a "click to refresh" badge.

:::tip
Follow along in the actual example — run it, open two tabs, and watch the behavior described in
each step before moving on to the next.
:::

## What you'll build

A server-rendered blog with three entity types — users, posts, comments — and two live behaviors:

* **Patch** — editing a post or comment body pushes the change silently to every connected tab;
  no spinner, no reload.
* **Stale** — creating or deleting a post or comment marks the affected view as stale; a
  "click to refresh" badge appears rather than reordering or removing items from under the reader.

**Stack:** Vite SSR · Hono · `@hono/node-ws` · PGlite · Drizzle ORM · `rxfy-server` · `rxfy-ws`

## File layout

The files touched in this guide:

:::file-tree

* src/
  * blog/
    * resources.ts
  * db/
    * schema.ts
  * entry-server.tsx
  * entry-client.tsx
* server/
  * live.ts
  * ws.ts
  * api.ts
    :::

::::steps

### Define resources

Resources are the bridge between your Drizzle tables and rxfy's normalized model stores.
[rxfy-server](/framework/server) exports `defineResource` and `createResourceRegistry`:

```ts [src/blog/resources.ts]
import { commentModel, postModel, userModel } from "./models.js";
import { createResourceRegistry, defineResource } from "rxfy-server/browser";
import { comments, posts, users } from "../db/schema.js";

export const userResource = defineResource({ table: users, model: userModel });
export const postResource = defineResource({ table: posts, model: postModel });
export const commentResource = defineResource({ table: comments, model: commentModel });

export { commentModel, postModel, userModel };

export const resources = createResourceRegistry([userResource, postResource, commentResource]);
```

`defineResource` pairs a Drizzle table with the rxfy model that governs it — the model's `getKey`
tells the live server which column is the primary key, and its schema validates incoming rows.
`createResourceRegistry` collects all three so the server has a single object to route live events
through.

The Drizzle schema itself is ordinary Drizzle — three `pgTable` definitions (users, posts,
comments) with text primary keys and timestamp defaults. Nothing rxfy-specific is required there.

### Stand up the live server

The live server owns the database connection, the in-memory pub/sub hub, and the signed-topic
keyer that scopes WebSocket subscriptions per entity. Create it once and export it:

```ts [server/live.ts]
import { createInMemoryHub, createServer, createTopicKeyer } from "rxfy-server";
import { resources } from "../src/blog/resources.js";
import { db } from "./db.js";

export const hub = createInMemoryHub();

export const live = createServer({
  db,
  resources,
  hub,
  keyer: createTopicKeyer({ secret: process.env.RXFY_SECRET ?? "dev-secret", windowMs: 10 * 60_000 }),
});
```

* `createInMemoryHub` — pub/sub bus for the current process. Replace with a Redis adapter for
  multi-instance deployments.
* `createTopicKeyer` — signs subscription topics with an HMAC so clients can only subscribe to
  entities the server explicitly grants them access to. The `windowMs` sets the token lifetime.
* `createServer` — wires the hub, keyer, and resource registry together into the `live` object
  your API routes will call.

### Wire the WebSocket endpoint

`createWsServer` from [rxfy-ws](/framework/ws) turns the hub into a WebSocket server. The Hono
`upgradeWebSocket` adapter bridges Hono's WS lifecycle to rxfy's connection interface:

```ts [server/ws.ts]
import { EventEmitter } from "node:events";
import type { UpgradeWebSocket } from "hono/ws";
import { createWsServer } from "rxfy-ws";
import { hub } from "./live.js";

const wsServer = createWsServer(hub);

export function liveRoute(upgradeWebSocket: UpgradeWebSocket) {
  return upgradeWebSocket(() => {
    const emitter = new EventEmitter();
    return {
      onOpen(_evt: Event, ws: { send: (data: string) => void }) {
        wsServer.handleConnection({
          send: (data: string) => ws.send(data),
          on: (event, cb) => emitter.on(event, cb),
        });
      },
      onMessage(evt: MessageEvent) {
        emitter.emit("message", evt.data);
      },
      onClose() {
        emitter.emit("close");
      },
    };
  });
}
```

`createWsServer(hub)` returns a handler that speaks the rxfy subscription protocol. The `EventEmitter`
adapts Hono's callback API to the `on("message")` / `on("close")` interface rxfy expects. Mount
`liveRoute(upgradeWebSocket)` on a `/live` path in your Hono app.

### Write through the server

All mutations go through `live.create`, `live.update`, and `live.delete`. Each method writes to the
database **and** broadcasts the appropriate live event to subscribed clients.

:::code-group

```ts [server/api.ts (setup)]
import { type Resource, type StateChannelDescriptor, touch } from "rxfy-server";
import { commentResource, postResource } from "../src/blog/resources.js";
import { postDetailState, postsState } from "../src/blog/states.js";
import { comments, posts } from "./db.js";
import { live } from "./live.js";

// A state doubles as its channel descriptor; a resource needs a raw-row cast for writes.
// See Writes (/framework/server/writes) for why each cast is required.
const postsChannel = postsState as unknown as StateChannelDescriptor;
const postDetailChannel = postDetailState as unknown as StateChannelDescriptor;
const postWriteResource = postResource as unknown as Resource<typeof posts>;
const commentWriteResource = commentResource as unknown as Resource<typeof comments>;
```

```ts [server/api.ts (posts)]
// POST /posts — create a post, mark the posts list as stale
const row = await live.create(
  postWriteResource,
  { id: newId(), userId, title, body },
  { touch: [touch(postsChannel, {})] },
);

// PATCH /posts/:id — update fields, push a live patch to every reader
const row = await live.update(postWriteResource, c.req.param("id"), patch);

// DELETE /posts/:id — delete the row, mark the posts list as stale
await live.delete(postResource, c.req.param("id"), { touch: [touch(postsChannel, {})] });
```

```ts [server/api.ts (comments)]
// POST /posts/:id/comments — create a comment, stale the post detail view
const row = await live.create(
  commentWriteResource,
  { id: newId(), postId, name, body },
  { touch: [touch(postDetailChannel, { postId })] },
);

// PATCH /comments/:id — update the comment body, push a patch
const row = await live.update(commentWriteResource, c.req.param("id"), patch);

// DELETE /posts/:postId/comments/:id — delete, stale the post detail view
await live.delete(commentResource, c.req.param("id"), { touch: [touch(postDetailChannel, { postId })] });
```

:::

**`update` emits a `patch`** — the new field values are merged into the normalized store on every
connected client immediately, with no round-trip. The UI re-renders in place.

**`create` and `delete` with `touch` emit a `stale`** — the named state channel (e.g.
`postsChannel`) is flagged as stale on every client that holds it. The client does not re-fetch
automatically; instead it surfaces a badge so the user can refresh when convenient.

`touch(channel, params)` identifies the exact query to stale. For the posts list that is
`touch(postsChannel, {})` (no params); for a post detail it is
`touch(postDetailChannel, { postId })` so only readers of that specific post see the badge.

:::note
`live.update` with no `touch` option produces only a `patch`. Only mutations that change the
membership of a list (create, delete) need `touch` to signal the list is outdated.
:::

### SSR with grants

During server-side rendering the server populates a `ModelRegistry` with the fetched entities, then
uses `live.grant` to sign subscription tokens for everything it rendered. Those tokens are embedded
in the HTML via `hydrationScript` and picked up by the client after hydration.

```tsx [src/entry-server.tsx]
export function render(url: string): Promise<{ html: string; state: string }> {
  const registry = createModelRegistry();
  const route = matchRoute(new URL(url, "http://localhost").pathname);

  return new Promise((resolve, reject) => {
    const { pipe } = renderToPipeableStream(
      <StrictMode>
        <StoreProvider registry={registry} ssr>
          <Suspense fallback={null}>
            <App url={url} />
          </Suspense>
        </StoreProvider>
      </StrictMode>,
      {
        onAllReady() {
          const sink = new PassThrough();
          let html = "";
          sink.on("data", (chunk: Buffer) => (html += chunk.toString()));
          sink.on("end", () => {
            const grants = live.grant(registry, {
              entities: [postResource, userResource, commentResource],
              states: routeStates(route),
            });
            resolve({ html, state: hydrationScript({ ...dehydrate(registry), grants }) });
          });
          pipe(sink);
        },
      },
    );
  });
}
```

`live.grant` inspects the registry to see which entity ids were actually rendered and which state
channels were queried, then returns signed tokens for those subscriptions only. The result is merged
into the existing `dehydrate(registry)` snapshot and serialized to a `<script>` block by
`hydrationScript`. See [Grants and live hydration](/framework/server/grants) for the full grant API.

The API routes do the same thing per-request, for clients that fetch data client-side after the
initial SSR:

```ts [server/api.ts (grant per route)]
const grants = live.grant(registry, {
  entities: [postResource, userResource],
  states: [{ state: postsChannel, params: {} }],
});
return c.json({ data, grants });
```

### Hydrate and go live

On the client, create the WebSocket transport and live client, read the SSR grants out of the
injected script tag, then pass the live client to `StoreProvider`:

```tsx [src/entry-client.tsx]
import { createModelRegistry } from "rxfy";
import { createLiveClient, readSsrGrants, StoreProvider } from "rxfy-react";
import { createWsClient } from "rxfy-ws/client";
import { App } from "./App.js";

const registry = createModelRegistry();
const liveClient = createLiveClient({
  registry,
  transport: createWsClient({
    url: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/live`,
  }),
  grants: readSsrGrants(),
});

hydrateRoot(
  document.getElementById("root") as HTMLElement,
  <StrictMode>
    <StoreProvider registry={registry} ssr liveClient={liveClient}>
      <App url={location.pathname} />
    </StoreProvider>
  </StrictMode>,
);
```

* `createWsClient` — builds the WebSocket transport. The URL switches between `ws://` and `wss://`
  based on the page protocol.
* `createLiveClient` — subscribes to all the grants returned by `readSsrGrants()` and wires
  incoming events to the shared registry.
* `readSsrGrants()` — deserializes the grant tokens from the `hydrationScript` block in the HTML.
* `StoreProvider liveClient={liveClient}` — makes the live client available to all hooks so that
  `useStateData` and `useModelStore` calls can receive stale notifications.

See [Live client](/react/live-client) for the full client API.

::::

## See it live

1. Open the app in two browser tabs.
2. **Edit a post body in tab A.** Tab B silently updates — no spinner, no reload. The body field
   was sent as a `patch` by `live.update` and merged directly into the normalized store.
3. **Create a new post in tab A.** Tab B shows a refresh badge on the posts list. The server called
   `touch(postsChannel, {})`, which emitted a `stale` event. The existing list is not disrupted;
   the reader chooses when to reload.
4. **Delete the post in tab A.** The same refresh badge appears in tab B for the same reason.

The distinction matters for user experience: patches are invisible and immediate; stale signals
let the user read without interruption and reload at will.

## Run it

```bash
pnpm --filter vite-blog-framework dev
```

The dev server starts at `http://localhost:5173`. It uses PGlite (an in-process Postgres) so no
database setup is required.

## What you built

* A **resource registry** that pairs Drizzle tables with rxfy models.
* A **live server** (`rxfy-server`) that writes to the database and broadcasts events through an
  in-memory hub.
* A **WebSocket endpoint** (`rxfy-ws`) bridged to Hono's WS adapter.
* API routes that distinguish between `patch` (edit) and `stale` (create/delete) live events by
  using `live.update` vs `live.create`/`live.delete` with `touch`.
* **SSR grants** baked into the hydration script so the client subscribes only to what it rendered.
* A **live client** on the browser that applies patches in place and surfaces stale badges.

## Where to go next

* [rxfy-server](/framework/server) — full API for `createServer`, `defineResource`, `createInMemoryHub`.
* [rxfy-ws](/framework/ws) — WebSocket transport reference.
* [Grants and live hydration](/framework/server/grants) — how grant tokens work and how to scope them.
* [Live client](/react/live-client) — `createLiveClient`, `readSsrGrants`, and stale handling in React.
* [Server-Side Rendering](/core-concepts/ssr) — `dehydrate`, `hydrate`, and `hydrationScript` for non-live SSR.
