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

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](/docs/api-server) 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:

```tsx
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:

```ts
// 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:

```tsx
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:

```tsx
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

- [Compose the UI with fragments](/docs/fragments) - build one root query for the
  whole first-paint component tree.
- [The API server](/docs/api-server) - `handleReadJson`, named query resolution,
  and auth policy.
- [The browser client](/docs/client) - the wasm client that takes over after
  hydration.
- [Scaffold with create-rindle](/docs/create-rindle) - a starter that includes
  this SSR handoff.

---

[View this page on Rindle](https://rindle.sh/docs/ssr)
