Build a synced app

Server rendering

Preload named Rindle queries during SSR, dehydrate first-paint rows, hydrate the browser store, then hand off to the live wasm client without a flash.

View as Markdown

Server rendering in a synced Rindle app is a first-paint preload, not a second data model. The server reads the route’s named queries once, embeds those rows in the HTML, and the browser uses the same rows for hydration. After hydration, the normal wasm client boots, opens the live subscription, and reconciles from the daemon stream.

That gives you SSR without changing component code:

  • route loaders preload the named queries the route renders;
  • the server calls your API authority with handleReadJson;
  • createServerStore dehydrates a first-paint cache;
  • the browser hydrates a seed store for the server render and hydration pass;
  • after mount, createRindleClient boots in the browser and takes over.

The wasm engine is browser-only in this flow. Do not construct it during SSR or prerender.

Preload in the route loader

Each route owns the first-paint queries it needs. A TanStack Start route looks like this:

import { createFileRoute } from "@tanstack/react-router";
import type { DehydratedState } from "@rindle/client";
import { issuesPageQuery } from "../components/IssueList.queries.ts";

export const Route = createFileRoute("/")({
  loader: async (): Promise<{ rindle: DehydratedState }> => {
    if (!import.meta.env.SSR) return { rindle: {} };

    // Dynamic import: ssr.ts builds the daemon client and reads server env.
    const { preloadRindle } = await import("../ssr.ts");
    return { rindle: await preloadRindle([issuesPageQuery({ limit: 50 })]) };
  },
  component: IssuesPage,
});

The query is the same defineQuery value the browser passes to useRoot or useQuery. The loader sends only its name and args through the authority; it does not trust or ship a client-built AST.

Read once through the authority

Keep the preload helper server-only. It builds the same API server factory your HTTP route uses, then calls handleReadJson in-process:

// src/ssr.ts
import {
  createServerStore,
  type DehydratedState,
  type OneShotQueryFn,
  type OneShotResult,
  type Query,
} from "@rindle/client";
import type { ApiContext } from "@rindle/api-server";

// createAppApi is YOUR factory wrapping createRindleApiServer (queries + mutators +
// daemon client) — the create-rindle template ships one in server/app-api.ts.
import { createAppApi } from "../server/app-api.ts";
import { schema } from "../shared/app-def.ts";

const readInProcess: OneShotQueryFn = async ({ name, args }): Promise<OneShotResult> => {
  if (!name) throw new Error("SSR preloads must use named queries");

  const api = createAppApi({
    daemonUrl: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600",
    daemonToken: process.env.RINDLE_DAEMON_TOKEN ?? "dev-daemon-token",
  });
  const context: ApiContext<undefined> = { user: undefined, request: undefined };

  return (await api.handleReadJson({ name, args }, context)) as OneShotResult;
};

export function preloadRindle(
  queries: Array<Query<any, any, any>>,
): Promise<DehydratedState> {
  return createServerStore(schema, { query: readInProcess }).preloadAll(queries, {
    // A failed read degrades to no seed for THAT query — the live client fills it in
    // after hydration — instead of failing the whole page. Without onError it is silent.
    onError: (_query, err) => console.error("[ssr] preload failed; rendering without its seed:", err),
  });
}

preloadAll runs the reads concurrently, then dehydrates. It is the loader-phase convenience over createServerStore(...).preload(query) + .dehydrate().

If your API tier is a separate service, make readInProcess call /api/rindle/read instead. The shape is the same: one named query in, assembled rows out, no subscription.

Merge route slices

Nested routes can each return a { rindle } slice. Merge every matched route’s slice in the root document and pass the result to your Rindle app provider:

import { useMemo } from "react";
import { HeadContent, Outlet, Scripts, createRootRoute, useMatches } from "@tanstack/react-router";
import type { DehydratedState } from "@rindle/client";
import { RindleApp } from "../RindleApp.tsx";

export const Route = createRootRoute({
  shellComponent: RootDocument,
});

function RootDocument() {
  const matches = useMatches();
  const ssrState = useMemo<DehydratedState>(() => {
    const merged: DehydratedState = {};
    for (const match of matches) {
      const slice = (match.loaderData as { rindle?: DehydratedState } | undefined)?.rindle;
      if (slice) Object.assign(merged, slice);
    }
    return merged;
  }, [matches]);

  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <RindleApp ssrState={ssrState}>
          <Outlet />
        </RindleApp>
        <Scripts />
      </body>
    </html>
  );
}

The server render and the browser’s hydration render now see the same seed data.

Hydrate, then go live

<RindleSSR> from @rindle/react owns the handoff. During the server render and the first browser hydration pass it backs the tree with a transport-less seed store (a store over OneShotBackend, which opens no transport) hydrated from ssrState, so useQuery reads the seeded rows and the server and client produce identical markup with no engine on either side. After hydration it calls your boot, hydrates the live store from the same snapshot (so the swap keeps the SSR rows — no flash), and swaps the provider to the live store.

Bind your schema and browser boot into it. This is the whole RindleApp.tsx the create-rindle template ships:

import type { ReactNode } from "react";
import { RindleSSR } from "@rindle/react";
import type { DehydratedState } from "@rindle/client";

import { schema } from "../shared/app-def.ts";
import { bootClient } from "./rindle-client.ts";

export function RindleApp({
  ssrState,
  children,
}: {
  ssrState: DehydratedState;
  children: ReactNode;
}) {
  return (
    <RindleSSR schema={schema} ssrState={ssrState} boot={bootClient}>
      {children}
    </RindleSSR>
  );
}

bootClient should dynamically import @rindle/optimistic and @rindle/wasm so the SSR bundle never evaluates the wasm engine. <RindleSSR> calls it exactly once, after hydration, and never during the server render.

Where fragments fit

Fragments are what keep SSR from becoming a first-paint waterfall. The route preloads one composed root query, useRoot retains that same coverage after hydration, and every child component reads its slice with useFragment. If a child opens a new useQuery during render, that is a second query the route did not preload, so it will wait for the browser’s live client.

Keep query and fragment modules React-free: they should import @rindle/client, schema, relationships, and sibling fragments, not .tsx components. Then the browser, API server, and SSR loader can all import the same query values.

Auth and freshness

SSR runs under whatever identity your server can verify for that request. Public pages often preload with user: undefined; authenticated pages should pass the same verified principal your API route would pass. The authority still validates args and policy before resolving the query.

The embedded rows are only a first-paint seed. As soon as the browser client boots, the live subscription reconciles against the daemon stream. preloadAll already fails soft: a failed read degrades to no seed for that query (your onError fires) and the live client fills it in after hydration, so a transient authority blip never breaks the page.

Next steps