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
- 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. - Hooks wait for their rows.
useLiveQuerysuspends on the server until its query’s rows arrive, then renders them. - 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.
- 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. They don’t change.
Components
Get db from a provider instead of importing it:
import { useLiveQuery } from "@rindle/react";
type Note = { id: number; title: string };
export function Notes() {
const { rows, status, error } = useLiveQuery<Note>("/api/notes");
if (error) return <p>Couldn't load notes: {error.message}</p>;
if (status === "loading") return <p>Loading…</p>;
return <ul>{rows.map((n) => <li key={n.id}>{n.title}</li>)}</ul>;
}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:
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<Response> {
const user = await getUser(request); // your auth
const db = createSSRClient({ rindle, request, userID: user?.id });
const stream = await renderToReadableStream(
<RindleProvider client={db}>
<App />
</RindleProvider>,
);
await stream.allReady;
const app = await new Response(stream).text();
return new Response(
`<!doctype html>
<div id="root">${app}</div>
<script>${db.dehydrateScript()}</script>
<script type="module" src="/client.js"></script>`,
{
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:
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")!,
<RindleProvider client={db}>
<App />
</RindleProvider>,
);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
timeoutrendersstatus: "loading". It stays live, and its rows arrive in the browser. - Refused queries. If an endpoint answers
401or403, the hook renderserroron the server, and the browser renders the same error. renderToStringdoesn’t wait for queries. Components render their<Suspense>fallback, and the browser fills them in. Use a streaming renderer that waits, likerenderToReadableStreamwithallReady.
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
fetchso endpoint calls stay in the same process. - React Router 7, Remix, TanStack Start: create the SSR client and wrap the app in
RindleProviderin 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.
// 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<string, SSRClient> };
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<string> {
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;
}// 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 (
<html lang="en">
<body>
<RindleRoot ssrID={ssrID}>{children}</RindleRoot>
</body>
</html>
);
}// 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<typeof createReactiveStreamClient> | 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<string, SSRClient> }).__rindleRenders!.get(ssrID)!;
// Count queries still loading, so the script is written once they have all arrived.
const loads = new Map<string, Promise<void>>();
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 <script dangerouslySetInnerHTML={{ __html: server.db.dehydrateScript() }} />;
});
return <RindleProvider client={state.browser ?? state.server!.client}>{children}</RindleProvider>;
}Pages then call useLiveQuery("/api/notes") in client components, with no <Suspense> around
the live queries. A complete example, with a Playwright test, is
apps/ssr.
Costs
- Each server-rendered page view holds its queries briefly, until the browser takes over. That includes crawlers and link previews. Skip the provider for known bots if that matters.
- The page grows by the data it shows. About the size of the rows themselves.