Skip to content
Guides contents

GuidesUI & local state

Server rendering

Read named queries on the server, seed the rendered page, and hand the browser over to live subscriptions.

View as Markdown

Server-side rendering (SSR) lets the server put query results in the first HTML response. The browser displays those results before its WebAssembly engine starts. After hydration, the same components read from the live browser store.

This guide builds the Rindle modules for a small, public issue list. It is an independent example with its own two-column table, separate from the scaffold and manual quickstart. It defines every helper used here. Your React framework supplies the HTTP server, HTML document, and transport for loader data. The TanStack Start guide connects these exact modules to routes and a root document.

SSR is optional. A browser-only application can follow the synced-app quickstart without these modules.

What happens during a page load

Stage What Rindle does
Server loader Reads the named query through your API authority, then creates a serializable snapshot
Server render React reads the snapshot without starting an engine or subscription
Browser hydration React reads the same snapshot, so its initial markup matches the server
Live browser Starts the browser client, opens subscriptions, and replaces the snapshot with live query data

The serialized snapshot is called a seed. Creating it is dehydration. Restoring it is hydration. A seed contains projected query results, not a copy of the browser’s normalized tables or its pending mutations.

A seed does not replace the live subscription. The browser still receives its initial subscription data and later changes. A one-shot server read can leave a query warm on the daemon, but reuse depends on routing and visibility scope.

1. Define the example data and query

Use a Rindle data tier with live-query support and a Vite-based React SSR project. Install the packages used on this page:

pnpm add @rindle/client @rindle/optimistic @rindle/wasm @rindle/react @rindle/api-server
pnpm add -D @rindle/cli

If the project has no rindle.ncl, create its local data-tier configuration:

pnpm exec rindle init

Create a migration for this example:

-- migrations/0001_init.sql
CREATE TABLE issue (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL
);

Apply migrations and generate the TypeScript schema through your development command. For an existing Vite project, the command is:

pnpm exec rindle dev --migrate --gen shared/schema.gen.ts -- vite dev

This command supplies the server’s RINDLE_URL and RINDLE_DATABASE_TOKEN. The CLI guide explains local process management.

The generated file contains the following table definition. The generator owns this file:

// shared/schema.gen.ts — generated from the SQL migration
import { createSchema, number, string, table } from "@rindle/client";

export const issue = table("issue")
  .columns({ id: number(), title: string() })
  .primaryKey("id");

export const schema = createSchema({ tables: [issue] });

Define a named query in a module that both server and browser can import:

// shared/queries.ts
import { defineQuery, newQueryBuilder } from "@rindle/client";
import { schema } from "./schema.gen.ts";

const q = newQueryBuilder(schema);

export const recentIssuesQuery = defineQuery(
  "recentIssues",
  () => q.issue.orderBy("id", "desc").limit(50),
);

The server resolves the name recentIssues to this definition. The browser uses the same definition to identify its local view and request a live subscription. This example has no mutation API. Populate or change the table through your SQL client to observe live updates.

2. Define the server authority

Create this server-only factory. createReadApi is application code defined here, not a Rindle export:

// server/ssr-api.ts
import { createRindleApiServer, registerQueries } from "@rindle/api-server";
import { recentIssuesQuery } from "../shared/queries.ts";

export function createReadApi() {
  return createRindleApiServer<undefined>({
    rindle: {}, // Uses RINDLE_URL and RINDLE_DATABASE_TOKEN from the server environment.
    queries: registerQueries<undefined>([recentIssuesQuery]),
    authorizeQuery: () => true, // This example's issue list is public.
    authorizeMutation: () => false,
  });
}

rindle: {} creates the trusted database connections from the server environment. The database token never belongs in loader data or browser code.

The API exposes different read operations:

  • handleReadJson({ name, args }, context) returns query rows once. SSR uses this operation.
  • handleQueryJson({ name, args, ... }, context) grants a query lease. The live browser uses this operation before subscribing.

Both operations apply query authorization and resolve the query on the server. The TanStack guide mounts the lease handler for this example. Without that endpoint, the page can have an SSR seed but cannot establish its live subscription.

3. Read queries before rendering

Define a preloader that creates a fresh server store for each call:

// server/preload.ts
import { createServerStore } from "@rindle/client";
import type { AnyQuery, OneShotResult } from "@rindle/client";
import { schema } from "../shared/schema.gen.ts";
import { createReadApi } from "./ssr-api.ts";

export async function preloadQueries(queries: readonly AnyQuery[]) {
  const api = createReadApi();
  try {
    const server = createServerStore(schema, {
      query: async ({ name, args }) => {
        if (name === undefined) throw new Error("SSR requires a named query");
        const result = await api.handleReadJson(
          { name, args },
          { user: undefined, request: undefined },
        );
        // The API returns assembled rows; OneShotResult narrows their cell types.
        return result as OneShotResult;
      },
    });

    return await server.preloadAll([...queries], {
      onError: (query, error) => console.error("SSR preload failed", query.name, error),
    });
  } finally {
    api.close();
  }
}

createServerStore supplies a read-only store for SSR. It does not start a WebAssembly engine or open subscriptions. Its injected query function performs the actual reads. Calling the API in-process avoids an HTTP request back into your app. The API still contacts the data tier.

This factory creates an API instance for each preload call. The finally block closes its database connection after all reads finish.

preloadAll reads the supplied queries concurrently and returns a DehydratedState. Each entry contains projected rows and a commit watermark, keyed by the local query definition. The library handles this representation. Pass it through your framework’s loader-data serializer without modifying it.

If a read fails, preloadAll omits that query’s seed and calls onError. Other queries can still render. Without onError, these read failures are silent. For a page that must fail when a query fails, call server.preload(query) and then server.dehydrate() instead. preload propagates the error.

4. Define browser-only startup

Create one client for the page’s lifetime. bootClient is application code that memoizes its startup promise:

// src/rindle-client.ts
import { schema } from "../shared/schema.gen.ts";

async function startClient() {
  if (typeof window === "undefined") {
    throw new Error("The live Rindle client must start in the browser");
  }

  const [{ createRindleClient }, { initWasm }, { default: wasmUrl }] = await Promise.all([
    import("@rindle/optimistic"),
    import("@rindle/wasm"),
    import("@rindle/wasm/pkg/rindle_bg.wasm?url"),
  ]);
  await initWasm(wasmUrl);

  return createRindleClient({
    schema,
    mutators: {}, // This example only reads public data.
    api: { url: "" }, // Same-origin /api/rindle/query.
  });
}

let clientPromise: ReturnType<typeof startClient> | undefined;

export function bootClient() {
  return clientPromise ??= startClient();
}

Vite’s ?url import supplies the WebAssembly asset URL. Keep Vite’s client type reference in your project so TypeScript recognizes asset imports.

The dynamic imports run only when bootClient starts in the browser. Importing this module during SSR does not construct the engine. The memoized promise also lets route loaders and the provider share the same client.

5. Render the same component on both sides

The component uses the same hooks before and after the store transition:

// src/IssueList.tsx
import { useQuery, useQueryStatus } from "@rindle/react";
import { recentIssuesQuery } from "../shared/queries.ts";

export function IssueList() {
  const query = recentIssuesQuery();
  const rows = useQuery(query);
  const status = useQueryStatus(query);

  if (rows.length === 0) {
    return <p>{status === "complete" ? "No issues yet." : "Loading issues…"}</p>;
  }
  return <ul>{rows.map((issue) => <li key={issue.id}>{issue.title}</li>)}</ul>;
}

For a React SSR framework without the TanStack adapter, wrap it in RindleSSR:

// src/SsrApp.tsx
import { RindleSSR } from "@rindle/react";
import type { DehydratedState } from "@rindle/client";
import { schema } from "../shared/schema.gen.ts";
import { bootClient } from "./rindle-client.ts";
import { IssueList } from "./IssueList.tsx";

export function SsrApp({ ssrState }: { ssrState: DehydratedState }) {
  return (
    <RindleSSR schema={schema} ssrState={ssrState} boot={bootClient}>
      <IssueList />
    </RindleSSR>
  );
}

Your framework’s server loader calls preloadQueries([recentIssuesQuery()]) and passes the returned value as ssrState. It must pass the same value to the browser’s first render. Use the framework’s serialization support rather than inserting raw JSON into a script.

RindleSSR renders from a transport-free seed store on the server and during browser hydration. After hydration, its effect calls bootClient, seeds the live store, and switches providers. The mounted query then acquires its live subscription.

A named view keeps its seed while the live client catches up. Its first server-confirmed snapshot replaces the seed, including a confirmed empty result. Changing ssrState later is not a general store-update API. Live subscriptions own subsequent updates.

RindleSSR does not close the returned client when it unmounts. Your application owns that client and must call close() when it permanently disposes it. The component also has no built-in UI for a rejected boot promise. Startup error reporting and retry belong to the application’s client lifecycle.

Private data and request identity

The working example is public: both SSR and live queries use anonymous access. Passing a user value alone does not make a public query private.

For private data, your server must authenticate the incoming request before the preload. Supply that verified identity and request to handleReadJson through its ApiContext<User>. Use the same identity model and access rules for the browser’s lease requests. The browser sends session credentials. The server verifies them independently.

Keep these boundaries explicit:

  • Create a server store per request. Never share a seed between users.
  • Filter private queries by the authenticated identity and enforce query authorization.
  • Use matching query arguments and local query context for SSR and browser hydration.
  • Forward the request when authentication, tenant rules, or routing read its cookies or headers.
  • Exclude private seed responses from shared public caches.

The TanStack adapter forwards its loader context to preload. It does not extract a user from that context or authenticate a request. The authorization guide covers the query and mutation rules.

Next steps