# Welcome to Rindle > A simple and fast SQL database with live queries Think Convex, but simple, open, and cheap. Turso, but with live queries. Under the hood, Rindle is powered by a Rust port of [ZVM](https://github.com/rocicorp/rindle/blob/branch/docs/ZVM.md), the incremental query engine behind [Zero](https://zero.rocicorp.dev/). ZVM blends ideas from traditional and incremental query engines, making live queries cheap enough to be on by default. ## Features - **Simple** It's SQL plus `subscribe()`. You already know how to use it: `CREATE TABLE`, `INSERT`, `SELECT`. Database, not framework. - **Live** Agents get woken on changes. Users see what happened without refreshing. Idle queries cost no CPU and ~400b of memory. - **Fast** [First result in milliseconds, updates in microseconds](https://rindle-docs.preview.rocicorp.dev/performance.md). Thousands of live queries and thousands of writes/second on one core. - **Tree queries** One trailing `WITH` clause, and Rindle returns the tree of data your application actually wants, instead of a flat table to reassemble. - **Composable** Layer a sync client like `@rocicorp/zero-client` or TanStack DB on top for instant client-first reads and writes. Or run Rindle client-side only, as an embedded relational database with live queries. - **Cheap** Built to run on local NVMe with S3 as the log, so a database costs nothing when idle and almost nothing when active. Make one per app, one per agent, or let agents spawn their own. ## Ready to try? Head to [Get Started](https://rindle-docs.preview.rocicorp.dev/get-started.md). Agents, see [For Agents](https://rindle-docs.preview.rocicorp.dev/agents.md). --- # Get Started Rindle is in private beta. You need access to the [rocicorp/rindle](https://github.com/rocicorp/rindle) repo to follow these instructions. Email [aa@roci.dev](mailto:aa@roci.dev?subject=Rindle%20beta%20access) to get access. ## With Agents Paste a prompt like this into your agent: Prompt for your agent (Polls): ```text Build me a live polling app with Rindle. Use the skill https://rindle-docs.preview.rocicorp.dev/skill.md. One page creates a poll with a question and 2–5 options and gives me a share link. Anyone with the link sees the poll, votes once, and watches the counts update live, without refreshing. Render the pages on the server so the poll shows up on first load, then stays live. ``` Prompt for your agent (Chess): ```text Build me multiplayer chess with Rindle. Use the skill https://rindle-docs.preview.rocicorp.dev/skill.md. I create a game and get a link to send a friend, and we each see the board update the moment the other moves. The server enforces the rules: it accepts a move only if it is legal and it is that player's turn, so nobody can cheat by calling the API directly. I can also choose to play against an AI instead, which moves through the same server rules. Show check, checkmate, and the move list. ``` Prompt for your agent (Team chat): ```text Build me a Slack-style team chat with Rindle. Use the skill https://rindle-docs.preview.rocicorp.dev/skill.md. Channels, messages, threads, and emoji reactions, all updating live, with an unread count on each channel. Private channels and direct messages are visible only to their members, and the server checks membership on every read and write. ``` Prompt for your agent (Auction): ```text Build me a live auction site with Rindle. Use the skill https://rindle-docs.preview.rocicorp.dev/skill.md. Sellers list an item with a starting price and an end time, and bidders watch the price and bid history update live. The server decides which bids count: a bid must beat the current high bid and arrive before the auction closes, so two people bidding at once can't both win. ``` Prompt for your agent (Trivia): ```text Build me a live trivia game with Rindle, like Kahoot. Use the skill https://rindle-docs.preview.rocicorp.dev/skill.md. A host creates a quiz and shares a join code, and players join from their phones. The host moves through the questions, everyone answers against a countdown, and the leaderboard updates live. The server scores the answers, and players never receive the correct answer before the question closes. ``` Agents can also read these docs as [plain Markdown](https://rindle-docs.preview.rocicorp.dev/agents.md). ## By Hand Download the `rindle` binary: ```bash gh release download dev -R rocicorp/rindle -p install.sh -O - | sh ``` Start the database. It writes its `RINDLE_URL` to `.env`: ```bash ./rindle up ``` In another terminal, install the JS client: ```bash gh release download dev -R rocicorp/rindle -p install.sh -O - | sh -s -- client ``` Then, from any app: ```typescript import { createReactiveSqlClient } from "@rindle/reactive-sql-client"; const db = await createReactiveSqlClient({ rindle: { url: process.env.RINDLE_URL } }); // Applied once, by name. Re-running is a no-op; the live engine picks the change up. await db.migrate("0001_init", ` CREATE TABLE issue (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, closed INTEGER); CREATE TABLE comment (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id INTEGER, body TEXT); `); // Queries are trees -- `WITH` creates a subquery. type Issue = { id: number; title: string; comments: { body: string }[] }; const open = await db.materialize(` SELECT * FROM issue WHERE closed = 0 ORDER BY id DESC WITH comments OF comment ON issue_id = id (ORDER BY id DESC LIMIT 10) `); // Fires now, then after every commit that affects it. // In React, useLiveQuery from @rindle/react does this for you. open.subscribe((rows) => { console.clear(); console.table(rows.map(({ id, title, comments }) => ({ id, title, comments: comments.length, newest: comments[0]?.body, }))); }); // Plain SQL writes. Every copy writes to the same database, so AUTOINCREMENT ids never collide. const { result } = await db.sql.execute(`INSERT INTO issue (title, closed) VALUES ('first', 0)`); const issueId = result.lastInsertRowid; const comments = Array.from({ length: 1000 }, (_, i) => ({ sql: `INSERT INTO comment (issue_id, body) VALUES (?, ?)`, args: [issueId, `comment ${i}`], })); // Twenty to a transaction. Fifty commits land; the view never holds more than ten. for (let i = 0; i < comments.length; i += 20) await db.sql.batch(comments.slice(i, i + 20)); ``` Save that as `app.ts` and run it in two terminals: ```bash node --env-file=.env app.ts ``` The second copy sees the first one's writes live, and its view never holds more than ten comments. Next, see the [demos](https://rindle-docs.preview.rocicorp.dev/demos.md). --- # Why Live Queries In a normal database, you run a query and get a result. That result is instantly stale. Run a second query and it's stale too, but differently. The two results can disagree with each other, and there's no way to know. Not a great start for high-quality software, but you accept it — it's just how databases work. You build something. It has multiple users or agents collaborating. Everyone is changing the same data at the same time, and nobody knows what the others just did. Bad things happen. Making this work properly is called "real-time collaboration," but it's really just solving that same inconsistency problem. You could try to patch it: - Re-run queries on a timer. That's expensive, and it still leaves gaps. - Build a WebSocket system to tell the UI when important data changes. Now you get to maintain a whole separate live push system. Most people decide to live with it. It's just how databases work. ## *Why* do databases work this way? Because live queries are hard to do well. The obvious approach is to re-run each query when its underlying data changes. But the queries worth watching are usually the ones whose data changes most, so you re-run them constantly. That gets expensive. There are ways to fight this, but they often push the database toward custom query models and APIs, away from standard web architecture. So most teams pick a traditional database and live with stale results, rather than opt into a walled garden. ## Live queries, no walls Instead of re-running queries, what if we updated a query result precisely as the underlying data changed? This is called [Incremental View Maintenance](https://materializedview.io/p/everything-to-know-incremental-view-maintenance). IVM engines like [Feldera](https://www.feldera.com/) and [Materialize](https://materialize.com/) exist today, but they're built for analytics: a small number of queries known up front over huge datasets. They trade lots of memory and slow startup for optimal incremental updates. When we were building [Zero](https://zero.rocicorp.dev/), we needed something like this, but for application workloads. We wanted to sync large numbers of small dynamic queries. So we built [ZVM](https://github.com/rocicorp/rindle/blob/branch/docs/ZVM.md). It can maintain thousands of small queries on a single core at 1,000 writes per second. Until now, ZVM was an implementation detail of Zero — you couldn't use it on its own. Rindle factors it out into a standalone database. And because of IVM, we don't need a custom query model: it's just plain [SQL](https://rindle-docs.preview.rocicorp.dev/sql.md) with subscriptions. Use it as the basis of a [sync engine](https://rindle-docs.preview.rocicorp.dev/zero.md), or just use it as a database. **Don't like SQL?** You can of course use a [typed query builder](https://rindle-docs.preview.rocicorp.dev/query-builder.md) too. It's just syntax. The core idea is that Rindle is a traditional relational database. No more stale data. No inconsistent results. Your UI updates when the data does, and collaborators see each other's changes live. ## Bigger than it sounds Live queries sound like a minor feature. They're not. Beyond solving data inconsistency and collaboration, live queries are also the foundation for two of the biggest shifts in software today: - **Sync.** Efficient live queries are the key to general-purpose sync, because they enable clients to sync only the subset of data they care about. Rindle directly supports [Zero](https://rindle-docs.preview.rocicorp.dev/zero.md), but it can just as easily back [TanStack DB](https://rindle-docs.preview.rocicorp.dev/tanstack-db.md) or any other sync engine. - **Agents.** Long-lived agents need to react when data changes. Live queries wake them on the change, instead of having them poll or re-read everything. ## Why Rindle We built ZVM because we needed it for Zero. But live queries shouldn't be a special feature of exotic databases. They should just be how data works. [Get Started](https://rindle-docs.preview.rocicorp.dev/get-started.md) --- # Demos ## Scale Thousands of open queries. 1k writes/second. 1 GB of data. 1 vCPU. ![The Agent Desk demo: 4,100 live queries and 1,000 writes/second on one core](https://rindle-docs.preview.rocicorp.dev/scale.webp) Running live, with the same page on two hosts in front of one shared backend: - Vercel: [https://rindle-scale-demo.preview.rocicorp.dev/](https://rindle-scale-demo.preview.rocicorp.dev/) - Cloudflare: [https://rindle-scale-demo.replicache.workers.dev/](https://rindle-scale-demo.replicache.workers.dev/) Try it locally: ```bash cd rust cargo run -p rindle-scale-demo --profile release-server -- --no-guide ``` ## Full-Stack Sync Pair with TanStack DB or `@rindle/sync-client` for instantaneous client-first reads and writes throughout your app. Because the app controls precisely the data to sync with queries, the oversync problem common with sync is elegantly avoided. The app can tune what it presyncs to balance startup time and interaction speed. ![zbugs running on Rindle](https://rindle-docs.preview.rocicorp.dev/sync.webp) Try it locally (needs Docker for Postgres; this config still uses a Postgres upstream): ```bash pnpm install pnpm --filter @rindle/zbugs build:runtime # builds the Rindle binaries cd apps/zbugs && pnpm dev # starts Postgres, the Rindle gateway + follower, and Vite on :5173 ZERO_UPSTREAM_DB='postgres://rindle:rindle@127.0.0.1:/app' pnpm db-seed # seed it, using the port `pnpm dev` printed ``` ## Client-Side Live Queries The core Rindle database is a 200KB library, gzip-compressed. It works on the web via wasm, or on iOS and Android natively. It provides a full relational, embedded database with live queries that answer and update in microseconds. Hook it to the Rindle server for a full, opinionated stack, or to your own server however you want. ![The wasm canvas demo](https://rindle-docs.preview.rocicorp.dev/canvas.webp) Try it locally: the demo lives in its own repo, [rocicorp/wasm-canvas-demo](https://github.com/rocicorp/wasm-canvas-demo). ## Streaming Queries Against Any Change Source Put Rindle downstream of your existing database, Kafka stream, whatever. Delete your websockets, webhooks, and API. Agents can directly watch any relational query they want, efficiently. Rindle becomes shared infra within your org that agents use to watch changing data. ![The Wikipedia firehose demo](https://rindle-docs.preview.rocicorp.dev/wiki.webp) Rindle sits downstream of Wikimedia's live edit firehose. Every browser that opens attaches to the same maintained boards, so N readers cost one pipeline, not N. Try it locally: the data tier in one terminal, the UI in another. ```bash cd apps/wiki-demo && pnpm dev # boots the write-master/follower pair + ingests the live Wikimedia feed cd product-page && pnpm dev # then open http://localhost:3200/wiki ``` --- # Architecture Rindle fits into your architecture like any other database. Clients typically don't talk to it directly. Access goes through your API server, which enforces business logic and authorization. The only difference is that Rindle has *sessions*. A session is a single streaming connection, shared by all of a client's open queries. `addQuery` and `removeQuery` change which queries are synced over it. *Diagram: The client talks only to your API server. The client calls your API server: one route opens the Rindle stream, and the others are ordinary endpoints, such as one that watches the user's notes and one that shares a note. Your API server calls the Rindle server to connect, to add a query, and to execute writes. Rindle's stream flows back to the API server, which passes it through to the client.* ## How it works 1. **The page opens one stream.** The browser opens a stream to your server, at `/api/rindle-connect`. Your server checks the user with your normal auth and returns Rindle's stream unchanged. Platforms like Vercel and Cloudflare pipe it through without your code touching each byte. 2. **Each query is an endpoint.** Framework-specific bindings like `useLiveQuery()` call your endpoints. The endpoint checks the user, picks the SQL, and calls `rindle.addQuery`. Rindle runs the query and sends its rows down the stream the page already has open. 3. **Writes are endpoints too.** They check the user and run ordinary SQL. Every live query the write affects updates on its own, for every user who can see it. *Diagram: One query and one write, step by step. The browser opens a stream through your server, and Rindle's stream comes back through it. The browser then calls a query endpoint, which checks the user, picks the SQL, and attaches it with rindle.addQuery. Rows arrive on the stream. Later the browser calls a write endpoint, which runs an UPDATE with an ownership check, and every affected query updates on the stream.* ## Setup Your server needs one setting, `RINDLE_URL`, which `rindle up` prints and writes to `.env`. Locally it's just an address. In production it also carries a password, like a Postgres URL (`https://:password@db.example.com`), so keep it in your server's environment and never send it to the browser. A complete example, for both Next.js and Cloudflare Workers, is in [`apps/notes`](https://github.com/rocicorp/rindle/tree/branch/apps/notes). Install Rindle in your app: ```bash gh release download dev -R rocicorp/rindle -p install.sh -O - | sh -s -- server react ``` Create the server's clients once, and import them from your routes: ```ts // lib/rindle.ts — server only import { createRindleSessions } from "@rindle/daemon-client"; import { createReactiveSqlClient } from "@rindle/reactive-sql-client"; export const rindle = createRindleSessions(); // reads RINDLE_URL export const db = await createReactiveSqlClient({ rindle: {} }); // for writes ``` ### The stream route One route per app. It authenticates the request and passes Rindle's stream through. - `clientID` names one live client (each `createReactiveSqlClient` has its own), and `queryID` names one of its queries. The page sends them in the `Rindle-Client` and `Rindle-Query` headers. Each client has its own stream and its own queries. - `userID` is optional. Rindle stores it with the client and rejects any later call for that client with a different `userID`. Without it, knowing the `clientID` is enough to change its queries, so pass it whenever there is a signed-in user. **Next.js on Vercel** (`app/api/rindle-connect/route.ts`): ```ts import { rindle } from "@/lib/rindle"; import { getUser } from "@/lib/auth"; // your auth export const maxDuration = 800; // the stream reconnects when Vercel ends the function export async function GET(request: Request) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); const clientID = request.headers.get("Rindle-Client"); return rindle.connect({ clientID, userID: user.id }); } export async function DELETE(request: Request) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); // the page stopped using a query const clientID = request.headers.get("Rindle-Client"); const queryID = request.headers.get("Rindle-Query"); return rindle.removeQuery({ clientID, queryID, userID: user.id }); } ``` **Cloudflare Workers** (`worker/index.ts`): ```ts import { rindle } from "../lib/rindle"; import { getUser } from "../lib/auth"; // your auth export default { async fetch(request: Request): Promise { const url = new URL(request.url); const user = await getUser(request); if (url.pathname === "/api/rindle-connect") { if (!user) return new Response(null, { status: 401 }); const clientID = request.headers.get("Rindle-Client"); const queryID = request.headers.get("Rindle-Query"); if (request.method === "DELETE") { return rindle.removeQuery({ clientID, queryID, userID: user.id }); } return rindle.connect({ clientID, userID: user.id }); } // …your query and write routes, below return new Response("Not found", { status: 404 }); }, }; ``` ### A query endpoint A query endpoint looks like any other endpoint: authenticate, then decide what this user may see. The difference is that it returns `rindle.addQuery(...)` instead of rows. The rows go down the stream. This one returns the notes a user owns, plus the notes shared with them: ```ts // app/api/notes/route.ts import { rindle } from "@/lib/rindle"; import { getUser } from "@/lib/auth"; export async function GET(request: Request) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); const clientID = request.headers.get("Rindle-Client"); const queryID = request.headers.get("Rindle-Query"); return rindle.addQuery({ clientID, queryID, userID: user.id }, ` SELECT * FROM note WHERE owner = ? OR EXISTS (SELECT 1 FROM share WHERE share.note_id = note.id AND share.user_id = ?) ORDER BY id DESC LIMIT 50 `, [user.id, user.id]); } ``` Read arguments from the request the way you normally would, and pass them as parameters: ```ts // app/api/notes/[id]/comments/route.ts export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); const { id } = await params; const clientID = request.headers.get("Rindle-Client"); const queryID = request.headers.get("Rindle-Query"); return rindle.addQuery({ clientID, queryID, userID: user.id }, ` SELECT comment.* FROM comment WHERE note_id = ? AND EXISTS (SELECT 1 FROM note WHERE note.id = comment.note_id AND note.owner = ?) ORDER BY id `, [Number(id), user.id]); } ``` If the SQL is not something Rindle can keep live, `rindle.addQuery` returns a `400` with the compiler's message, and the page sees it as the query's `error`. ### Use it in the page Create one live client per signed-in page, and close it on sign-out: ```tsx "use client"; import { useEffect, useState } from "react"; import { createReactiveSqlClient, type ReactiveStreamClient } from "@rindle/reactive-sql-client"; import { useLiveQuery } from "@rindle/react"; function useStreamClient(): ReactiveStreamClient | null { const [db, setDb] = useState(null); useEffect(() => { let client: ReactiveStreamClient | undefined; let closed = false; void createReactiveSqlClient({ stream: "/api/rindle-connect" }).then((c) => { if (closed) return c.close(); client = c; setDb(c); }); return () => { closed = true; client?.close(); }; }, []); return db; } type Note = { id: number; title: string; owner: string }; export function Notes() { const db = useStreamClient(); const { rows, status, error } = useLiveQuery(db, "/api/notes"); if (error) return

Couldn't load notes: {error.message}

; if (status === "loading") return

Loading…

; return
    {rows.map((n) =>
  • {n.title}
  • )}
; } ``` The client opens the stream the first time a component asks for a query, and one stream carries every query on the page. Render the signed-in part of the page with `key={user.id}`, so a different user gets a fresh client. If your endpoint answers `401` or `403`, that status shows up as `error`. Unmounting the component removes the query. ### A write endpoint Writes are ordinary endpoints that run SQL with your server's client. Put the ownership check in the SQL, so a user can never change a row they don't own, then check how many rows changed: ```ts // app/api/notes/[id]/share/route.ts import { db } from "@/lib/rindle"; import { getUser } from "@/lib/auth"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); const { id } = await params; const { userId } = await request.json(); const { result } = await db.sql.execute({ sql: `INSERT INTO share (note_id, user_id) SELECT id, ? FROM note WHERE id = ? AND owner = ?`, args: [userId, Number(id), user.id], }); if (result.rowsAffected === 0) return new Response(null, { status: 403 }); return new Response(null, { status: 204 }); } ``` The moment that row lands, the note appears in the other user's `/api/notes` results, on any page they have open. You don't notify anyone; the query does it. ## Authorization / Permissions You already know how to implement authorization – it's just an API endpoint that adds a query. There is no separate permission system, and no need for one. Your endpoints use ordinary business logic to decide which query to run: ```ts // app/api/notes/route.ts export async function GET(request: Request) { const user = await getUser(request); if (!user) return new Response(null, { status: 401 }); const ids = { clientID: request.headers.get("Rindle-Client"), queryID: request.headers.get("Rindle-Query"), userID: user.id, }; if (user.isAdmin) { return rindle.addQuery(ids, `SELECT * FROM note ORDER BY id DESC`, []); } return rindle.addQuery(ids, `SELECT * FROM note WHERE owner = ? ORDER BY id DESC`, [user.id]); } ``` ## More authorization examples | Rule | SQL | |---|---| | Only your own rows | `WHERE owner = ?` | | Rows shared with you | `OR EXISTS (SELECT 1 FROM share WHERE share.note_id = note.id AND share.user_id = ?)` | | Rows in your teams | `WHERE EXISTS (SELECT 1 FROM member WHERE member.team_id = doc.team_id AND member.user_id = ?)` | | Hide a column | Select only the columns this user may see: `SELECT id, title FROM …`, not `SELECT *`. Only the selected columns reach the browser. | | Different rules per role | Pick a different query in the endpoint: `if (user.isAdmin) …` | Always pass the user's id as a parameter from your server. Never take it from the request body or URL. ## Authorization is Live Note that because authorization in Rindle is based on live queries, the resulting authorization logic is also live. When a note is unshared, it disappears from the other user's open page right away, because their query no longer matches it. The same holds for anything the rule can see: team membership, a role column, an `archived` flag. Some rules live outside your data: a user logs out, gets banned, or has a role your auth provider holds. For those, end the user's streams: ```ts await rindle.disconnect({ userID: user.id }); ``` That stops every query for that user, in every tab. Their pages reconnect, your stream route checks them again, and anything they are no longer allowed to see stays gone. `rindle.disconnect({ userID, clientID })` disconnects one client. ## Deploying - **Keep Rindle private.** Only your servers need its address. Keep `RINDLE_URL` in your server's environment. - **Vercel:** set `maxDuration` on the stream route. When Vercel ends the function, the stream reconnects and the page's queries resume without calling your endpoints again. - **Cloudflare:** the stream route returns Rindle's response as is, so the Worker uses almost no CPU while a page is open. Add `"compatibility_flags": ["nodejs_compat"]` to your wrangler config so the Worker can read `RINDLE_URL`. --- # Server-Side Rendering Components use the same `useLiveQuery` hook on the server and in the browser. The server calls your query endpoints, renders the rows, and puts them in the page. The browser shows the page, then keeps the same queries live. It doesn't call your endpoints again. Server-rendered pages hold one user's data. Serve them with `Cache-Control: private, no-store` so a CDN never shares them. ## How it works 1. **The server render is a client.** It opens a stream to Rindle and calls your query endpoints as the signed-in user, forwarding their cookies. Your endpoints check the user and call `rindle.addQuery`, exactly as they do for the browser. 2. **Hooks wait for their rows.** `useLiveQuery` suspends on the server until its query's rows arrive, then renders them. 3. **The page carries the queries.** Along with the HTML, the page includes each query's data and the ID of the client that holds them. 4. **The browser takes over.** It builds its views from the page, so hydration matches the server's HTML, then opens the stream as the same client. Changes continue from where the server stopped. If the browser takes too long to take over, Rindle drops the server's queries. The browser then calls your endpoints as usual, and keeps showing the server's rows until fresh ones arrive. ## Setup SSR builds on the stream route and query endpoints from [Architecture](https://rindle-docs.preview.rocicorp.dev/architecture.md). They don't change. ### Components Get `db` from a provider instead of importing it: ```tsx import { useLiveQuery } from "@rindle/react"; type Note = { id: number; title: string }; export function Notes() { const { rows, status, error } = useLiveQuery("/api/notes"); if (error) return

Couldn't load notes: {error.message}

; if (status === "loading") return

Loading…

; return
    {rows.map((n) =>
  • {n.title}
  • )}
; } ``` The server renders many users' pages at once, so each request gets its own `db`. The provider is how a component finds it. A browser-only app can keep passing `db` directly. ### The server entry Create an SSR client for the request, render, then add its script to the page: ```tsx import { createRindleSessions, createSSRClient } from "@rindle/reactive-sql-client"; import { RindleProvider } from "@rindle/react"; import { renderToReadableStream } from "react-dom/server"; const rindle = createRindleSessions(); // reads RINDLE_URL export async function renderPage(request: Request): Promise { const user = await getUser(request); // your auth const db = createSSRClient({ rindle, request, userID: user?.id }); const stream = await renderToReadableStream( , ); await stream.allReady; const app = await new Response(stream).text(); return new Response( `
${app}
`, { headers: { "Content-Type": "text/html", "Cache-Control": "private, no-store", }, }, ); } ``` Call `dehydrateScript()` once, after the render is done. | Option | | |---|---| | `rindle` | Your `createRindleSessions()` object | | `request` | The page request. Endpoints resolve against its origin, and its `cookie` and `authorization` headers are forwarded to them | | `userID` | Optional. The same `userID` your endpoints pass to `rindle.addQuery` | | `fetch` | How endpoints are called. Defaults to the global `fetch`. Pass your app's own handler (a Worker's `fetch`, Hono's `app.fetch`) to call them in-process | | `timeout` | How long a query may take, in ms. Default 2,000 | ### The browser entry Create `db` from the page, then hydrate: ```tsx import { createReactiveSqlClient } from "@rindle/reactive-sql-client"; import { RindleProvider } from "@rindle/react"; import { hydrateRoot } from "react-dom/client"; const db = await createReactiveSqlClient({ stream: "/api/rindle-connect", hydrate: true }); hydrateRoot( document.getElementById("root")!, , ); ``` On a page rendered without SSR, `hydrate: true` does nothing, so the same entry works everywhere. ## Loading and errors - **Slow queries.** A query with no rows by `timeout` renders `status: "loading"`. It stays live, and its rows arrive in the browser. - **Refused queries.** If an endpoint answers `401` or `403`, the hook renders `error` on the server, and the browser renders the same error. - **`renderToString`** doesn't wait for queries. Components render their `` fallback, and the browser fills them in. Use a streaming renderer that waits, like `renderToReadableStream` with `allReady`. SSR needs React 19. Browser-only rendering works on React 18. ## Frameworks - **Hono, Cloudflare Workers, Express, or your own server:** as above. Pass your app's handler as `fetch` so endpoint calls stay in the same process. - **React Router 7, Remix, TanStack Start:** create the SSR client and wrap the app in `RindleProvider` in the server entry (`entry.server.tsx`), which receives the request. - **Next.js (App Router):** see below. ### Next.js Next.js renders client components on the server without the request, and a server component can't hand a client to them as a prop. So the root layout, which has the request, creates the SSR client and parks it under an id; a client component picks it up by that id during the server render, and writes the page's script with `useServerInsertedHTML`. ```tsx // lib/render.ts — server only import { headers } from "next/headers"; import { after, connection } from "next/server"; import { createSSRClient, type SSRClient } from "@rindle/reactive-sql-client"; import { rindle } from "./rindle"; const g = globalThis as { __rindleRenders?: Map }; const renders = (g.__rindleRenders ??= new Map()); // `userID` is the same id the stream route passes to `rindle.connect` (undefined for a public page). export async function beginRender(userID?: string): Promise { await connection(); // render per request, never at build time const incoming = await headers(); const proto = incoming.get("x-forwarded-proto") ?? "http"; const request = new Request(`${proto}://${incoming.get("host")}/`, { headers: incoming }); const db = createSSRClient({ rindle, request, userID }); const id = crypto.randomUUID(); renders.set(id, db); // Once the response is sent, wait for the browser to take the queries over, then forget them. after(async () => { db.dehydrateScript(); await Promise.race([db.handedOff, new Promise((r) => setTimeout(r, 10_000))]); renders.delete(id); }); return id; } ``` ```tsx // app/layout.tsx import { beginRender } from "@/lib/render"; import { currentUser } from "@/lib/auth"; // however your endpoints read the signed-in user import { RindleRoot } from "./RindleRoot"; export default async function RootLayout({ children }: { children: React.ReactNode }) { const ssrID = await beginRender((await currentUser())?.id); return ( {children} ); } ``` ```tsx // app/RindleRoot.tsx "use client"; import { useRef, useState, type ReactNode } from "react"; import { useServerInsertedHTML } from "next/navigation"; import { RindleProvider } from "@rindle/react"; import { createReactiveStreamClient, type SSRClient } from "@rindle/reactive-sql-client"; // One browser client per page load. Kept outside React state, because React's development Strict // Mode runs state initializers twice, and a second hydrating client would take over the queries. let browserClient: ReturnType | undefined; export function RindleRoot({ ssrID, children }: { ssrID: string; children: ReactNode }) { const [state] = useState(() => { if (typeof window !== "undefined") { browserClient ??= createReactiveStreamClient({ stream: "/api/rindle-connect", hydrate: true }); return { browser: browserClient }; } const db = (globalThis as { __rindleRenders?: Map }).__rindleRenders!.get(ssrID)!; // Count queries still loading, so the script is written once they have all arrived. const loads = new Map>(); let pending = 0; const client = { peek: db.peek, load(endpoint: string) { let p = loads.get(endpoint); if (!p) { pending++; p = db.load(endpoint).finally(() => pending--); loads.set(endpoint, p); } return p; }, }; return { server: { db, client, pending: () => pending } }; }); const written = useRef(false); useServerInsertedHTML(() => { const server = state.server; if (!server || written.current || server.pending() > 0) return null; written.current = true; return