Skip to content
Contents

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

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