# SSR and hydration

This page shows server rendering with the Next.js App Router. The same rules
hold in any framework that renders React on a server. The
[Next.js SSR example](https://ic-reactor.b3pay.net/v4/examples/next-ssr/) is a complete app that does all
of it, and each section below names the file it comes from.

The rules:

1. **One client per request.** Never build the client at module scope on a
   server: a module-scope client is shared by every request.
2. **The provider's factory lives in a `"use client"` module.** A function
   cannot cross from a Server Component into a client component.
3. **A server renders; it does not run.** The auth factory is never called,
   nothing reads `window` or `localStorage`, and no effect runs.
4. **The server is anonymous.** It prefetches as the anonymous principal, and
   the browser's hydrating render uses the same principal, so the keys match.
5. **A read through `queryOptions` is never retried on a server.** Nor is any
   read of the client's `QueryClient`. A direct call still re-sends a
   retryable read up to twice.

## One client per request

React's `cache()` memoizes for the length of one server request. Every Server
Component of the request gets the same client, and the next request gets a new
one:

```ts
// src/server/request-client.ts
import { createClient, type Client } from "@ic-reactor/core"
import { cache } from "react"

export const requestClient = cache((): Client =>
  createClient({ network: "ic", identity: "anonymous" })
)
```

It is anonymous because a server holds no session, and an anonymous client
refuses every update before sending it. There is nothing to dispose: it built
no auth, and a server's `QueryClient` sets no timers.

Outside a request, in a script or a plain test, every call of a `cache()`
function builds a new client. A Route Handler builds one client for each
request it serves, and calls the canister directly: a direct call never
touches a cache (`src/server/balance-route.ts` in the example).

## The provider

The layout is a Server Component. It renders a client module that holds the
provider:

```tsx
"use client"
// src/app/providers.tsx

import { createClient } from "@ic-reactor/core"
import { ReactorProvider } from "@ic-reactor/react"
import { AuthClient } from "@icp-sdk/auth/client"
import type { ReactNode } from "react"

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ReactorProvider
      client={() =>
        createClient({ network: "ic", auth: () => new AuthClient() })
      }
    >
      {children}
    </ReactorProvider>
  )
}
```

```tsx
// src/app/layout.tsx
import type { ReactNode } from "react"
import { Providers } from "@/app/providers"

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
```

The factory creates the client, so the provider owns it and disposes it when
it unmounts. It runs once per request in the server render, and once in the
browser tab, where it lives across client-side navigations because the layout
stays mounted.

`createClient` does no work until the client is used. So the same line runs on
a server, where the client is anonymous and `auth` is never called, and in a
browser, where `auth` runs once, on first use.

## Which network

The `network` option is part of every query key. On a server:

- `"ic"` and `"local"` work as they do anywhere.
- `"env"` has no `ic_env` cookie to read on a server. A `{ id }` target works.
  A `{ name }` target never resolves: every call rejects as `invalid_args`
  with code `canister_id_unresolved`, and sends nothing. In development the
  client logs a warning once per process. Use `{ id }` for every canister a
  server calls.
- Where `"env"` points a server is: `ICP_HOST` or `IC_HOST` when `ICP_NETWORK`
  (or `DFX_NETWORK`) is `"local"` (`http://127.0.0.1:4943` if neither host is
  set), and mainnet otherwise.

Keys match between the server and the browser only when their network segment
matches. The segment of `"ic"` is `"ic"`. The segment of an object network is
its `name`, or its `host` when it has none. The segment of `"env"` is the
resolved host, and a browser's host is often the page's origin while a
server's is its fallback host, so the two differ. For an app that prefetches,
use `"ic"`, or an object network with the same `name` on both sides.

## Prefetch and hand over

A Server Component prefetches with `client.queryOptions(...)`, so the keys
come from the client and never from a string you wrote. It then gives the
dehydrated cache to a `<HydrationBoundary>` around the client components that
read it.

```tsx
// src/app/page.tsx
import { isReactorError } from "@ic-reactor/core"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { connection } from "next/server"
import { actor, type Actor } from "@/canisters/icrc1"
import { LedgerCard } from "@/components/LedgerCard"
import { requestClient } from "@/server/request-client"

const LEDGERS = [
  { label: "ICP", id: "ryjl3-tyaaa-aaaaa-aaaba-cai" },
  { label: "ckBTC", id: "mxzaz-hqaaa-aaaar-qaada-cai" },
]

export default async function Home() {
  // Read mainnet when a request comes in, never while `next build` prerenders.
  await connection()
  const client = requestClient()

  await Promise.all(
    LEDGERS.flatMap(({ id }) => {
      const ledger = client.canister<Actor>(actor, { id })
      return [
        client.queryClient.prefetchQuery(
          client.queryOptions(ledger, "icrc1_symbol")
        ),
        client.queryClient.prefetchQuery(
          client.queryOptions(ledger, "icrc1_fee")
        ),
      ]
    })
  )

  return (
    <main>
      <HydrationBoundary state={dehydrate(client.queryClient)}>
        {LEDGERS.map(({ label, id }) => {
          const ledger = client.canister<Actor>(actor, { id })
          const { error } =
            client.queryClient.getQueryState(
              client.queryKey(ledger, "icrc1_symbol")
            ) ?? {}
          return error == null ? (
            <LedgerCard key={id} id={id} />
          ) : (
            <p key={id}>
              {label}: {isReactorError(error) ? error.kind : "failed"}
            </p>
          )
        })}
      </HydrationBoundary>
    </main>
  )
}
```

- `prefetchQuery` never throws. A failed read stays in the cache with its
  error, and you read it back with `getQueryState`.
- `dehydrate(client.queryClient)` is plain JSON as it is. The client's
  `QueryClient` rewrites `bigint`, `Uint8Array`, `NaN`, `Infinity`,
  `-Infinity`, `-0` and `undefined` into tagged JSON when it dehydrates, and
  reads them back exact when it hydrates. Amounts arrive as `bigint`, not as
  rounded numbers.
- TanStack dehydrates successful reads only. A failed read stays on the
  server, so render its error on the server, where `isReactorError` can read
  its `kind`. An `error.tsx` boundary would not help: in a production build
  Next replaces a Server Component's error with a generic one before the
  browser sees it.
- Reading a request value, or calling `connection()`, makes a page render for
  each request. Without that, `next build` would call mainnet while it
  prerenders the page, and keep a stale answer.

The component that reads the data builds the same options in render:

```tsx
"use client"
// src/components/LedgerCard.tsx

import { useClient } from "@ic-reactor/react"
import { useQuery } from "@tanstack/react-query"
import { actor, type Actor } from "@/canisters/icrc1"

export function LedgerCard({ id }: { id: string }) {
  const client = useClient()
  const ledger = client.canister<Actor>(actor, { id })
  const symbol = useQuery({
    ...client.queryOptions(ledger, "icrc1_symbol"),
    staleTime: 60_000,
  })
  const fee = useQuery({
    ...client.queryOptions(ledger, "icrc1_fee"),
    staleTime: 60_000,
  })
  return (
    <section>
      <h2>{symbol.data ?? "…"}</h2>
      <p>Fee: {fee.data?.toString() ?? "…"}</p>
    </section>
  )
}
```

**Why staleTime:** TanStack's default `staleTime` is 0, so every read the server hydrated would
  be stale on arrival, and each card would fetch it again as soon as it mounts.
  A minute after the server's read, a remount or a refocused window reads it
  again as the browser's own caller. Spread it over the client's options, as
  above; never build a key or a query function by hand.

## The hydrating render

When a page hydrates, the browser may hold a session. The server has none, and
the HTML has to match, so the hydrating render shows the anonymous caller:

- `useAuth()` is `"anonymous"` on the server and during hydration, whatever
  session the browser holds.
- `useClient()` builds keys for the same anonymous caller. In a browser that
  holds a session (an `AuthClient` reads a stored one synchronously) it
  returns a **view** of the client. Its `queryKey`, `queryOptions`, `caller()`
  and `authState()` are the anonymous caller's, so the page finds what the
  server dehydrated and matches the server's HTML. The canisters,
  `mutationOptions`, `signIn`, `signOut` and the `QueryClient` are the
  client's own, and a write signs as the caller current when it runs.
- Nothing is sent during the hydrating render.

Right after hydrating, React renders each component that calls `useClient()`
or `useAuth()` again, with the session. Each read then loads the user's own
keys once. Until the user's data arrives, a read shows its loading state.
Nothing read anonymously is reused for the user.

A tab whose caller is anonymous anyway (no session, or one that expired or is
signed in elsewhere) renders no `useClient()` component again, and no
`useAuth()` component unless its status differs (an expired or elsewhere
session).

So show the same thing signed out and while the session is read, and the page
does not flicker into a different layout. The example's header badge is
`anonymous` in the HTML of a signed-in tab, and switches right after
hydration.

For the rules on who is signed in, see [Auth](https://ic-reactor.b3pay.net/v4/guides/auth/).

## Streaming a slow section

A certified read is a replicated call that goes through consensus, a second or
two on mainnet. The example streams it: an async Server Component that awaits
the read, inside `<Suspense>`. React sends the fallback with the first HTML,
and the section follows in the same response.

```tsx
// src/app/account/page.tsx
import { principal, type Principal } from "@candid-core/schema"
import { connection } from "next/server"
import { Suspense } from "react"
import { actor, type Actor } from "@/canisters/icrc1"
import { requestClient } from "@/server/request-client"

async function CertifiedBalance({ owner }: { owner: Principal }) {
  const client = requestClient()
  const ledger = client.canister<Actor>(actor, {
    id: "ryjl3-tyaaa-aaaaa-aaaba-cai",
    certified: true,
  })
  const balance = await client.queryClient.fetchQuery(
    client.queryOptions(ledger, "icrc1_balance_of", { owner, subaccount: null })
  )
  return <p>Certified balance: {balance.toString()}</p>
}

export default async function Account() {
  await connection()
  const owner = principal("rkp4c-7iaaa-aaaaa-aaaca-cai")
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<p>Reading through consensus…</p>}>
        <CertifiedBalance owner={owner} />
      </Suspense>
    </main>
  )
}
```

A certified canister is another object with keys of its own, which end in
`"certified"`, so an uncertified answer is never served where a certified one
was asked for.

The example uses an awaited Server Component, and not `useSuspenseQuery` in a
client component over a prefetch that is not awaited. Both stream. But with
this library the Server Component is the one that holds up:

- A failure stays on the server, where `isReactorError` reads its `kind` into
  the HTML. A pending query that fails reaches the browser as an error that
  TanStack redacts unless told otherwise, and React carries no error's fields
  from the server to the browser.
- Its HTML does not depend on who the browser is signed in as. The browser
  keys every read by its own caller, so a signed-in visitor would never use a
  promise the server dehydrated for the anonymous caller, and would read
  everything again.

## What a signed-in reload costs

Moving from the anonymous render to the session has three accepted costs, and
one rule keeps them small. The library tests each. They come from the move,
and each cost has a fix.

### Do not keep what useClient returns past its render

Call `useClient()` in the body of each component that builds keys or read
options, on every render, and build them from what it returns there. Never
build them from a client held at module scope.

An effect or a callback that uses the client closes over the value of its own
render and lists it in its dependencies, as the `react-hooks/exhaustive-deps`
rule asks. Then it runs again with the client after hydrating:

```tsx
"use client"

import { useClient } from "@ic-reactor/react"
import { useEffect } from "react"
import { actor, type Actor } from "@/canisters/icrc1"

export function RefreshOnFocus({ id }: { id: string }) {
  const client = useClient()
  useEffect(() => {
    const ledger = client.canister<Actor>(actor, { id })
    const refresh = () =>
      void client.queryClient.invalidateQueries({
        queryKey: client.queryKey(ledger),
      })
    window.addEventListener("focus", refresh)
    return () => window.removeEventListener("focus", refresh)
  }, [client, id])
  return null
}
```

While a page hydrates, the result is a view for the anonymous caller, and a
view never moves on. Kept from the hydrating render anywhere else
(`useState(client)`, `useRef(client)`, a `useMemo` or `useCallback` with
`[]`, a module variable), it stays that view:

- the component shows the anonymous caller's data;
- a read it starts while the user is signed in is cancelled
  (`caller_changed`);
- a write through it still signs as the live caller;
- nothing warns about it.

A nested `ReactorProvider` given it holds the client the view was made over.

### A dehydrated boundary below a caller component is client-rendered

When a component that calls `useClient()` or `useAuth()` moves on to the
session, the update reaches the boundaries it renders. A Suspense boundary
that has not hydrated yet is then rendered on the client. That happens when
its lazy code is still loading, or when its streamed HTML has not arrived.
The boundary shows its fallback instead of the server's HTML until its content
is ready, and React 18 reports a recoverable error. Its data is still the
user's, and nothing is sent as anyone else. A `useAuth()` component moves on,
and costs the same, in a tab whose session expired or is signed in elsewhere
too.

To keep the server's HTML, render such a boundary where no component that
renders with the caller sits above it. A `useAuth()` header beside it is fine.
Or pass the boundary in as `children`: a component's own update does not
render its `children` prop again.

```tsx
"use client"

import { useAuth } from "@ic-reactor/react"
import type { ReactNode } from "react"

// Renders with the caller. Its children are made by the Server Component
// that renders it, so a boundary in them is not re-rendered when this moves.
export function Shell({ children }: { children: ReactNode }) {
  const { status } = useAuth()
  return <div data-status={status}>{children}</div>
}
```

```tsx
// A Server Component
import { Suspense } from "react"
import { Shell } from "@/components/shell"
import { SlowSection } from "@/components/slow-section"

export default function Page() {
  return (
    <Shell>
      <Suspense fallback={<p>Loading…</p>}>
        <SlowSection />
      </Suspense>
    </Shell>
  )
}
```

The example has no such boundary below a caller component. Its streamed
section is in a Server Component, and the client components above it, the
provider included, never render with the caller.

### A read the hydrating render built may be cancelled once

TanStack Query refetches stale data on mount, and an effect may fetch with the
hydrating render's options. Such a read is for the anonymous caller while the
user is current, so it is cancelled before anything is sent.

- A key that holds data (the server's) keeps it as it was, and a fetch of it
  resolves with that data.
- A key with none fails as `cancelled` with code `caller_changed`, until the
  anonymous caller is current again.

The hydrating render's own reads never show that error. A listener on the
`QueryCache`, an effect that awaits the fetch and, for a `useSuspenseQuery` read
the server rendered without dehydrating its data, React's
`onRecoverableError` (as the reported error's `cause` on React 19) see it.
Ignore `kind: "cancelled"` there:

```ts
import { isReactorError } from "@ic-reactor/core"
import { client } from "./ledger"

client.queryClient.getQueryCache().subscribe((event) => {
  if (event.type !== "updated" || event.action.type !== "error") return
  const error: unknown = event.action.error
  if (isReactorError(error) && error.kind === "cancelled") return
  console.error(error)
})
```

(In an app the client comes from `useClient()` or a module of the browser, not
from a module that a server also loads.)

### A useSuspenseQuery reader needs a Suspense boundary above it

The move to the session is a synchronous update, and a `useSuspenseQuery`
read with none of the user's data yet suspends it. Put a Suspense boundary
above every component that reads with `useSuspenseQuery`, on React 18 and 19.

- With a boundary above, it shows its fallback until the user's data arrives,
  then the data, and the rest of the page responds meanwhile.
- Without one, React 18 refuses the update ("A component suspended while
  responding to synchronous input") and unmounts the root: a signed-in reload
  renders nothing.
- Without one, React 19 keeps the server's HTML on screen, but until the
  user's data arrives, however long the read and its retries take, a click or
  any other update outside a transition commits nothing, and neither does a
  transition that renders the reading component again.

```tsx
"use client"

import { useSuspenseQuery } from "@tanstack/react-query"
import { useClient } from "@ic-reactor/react"
import { Suspense } from "react"
import { actor, type Actor } from "@/canisters/icrc1"

function Fee({ id }: { id: string }) {
  const client = useClient()
  const ledger = client.canister<Actor>(actor, { id })
  const fee = useSuspenseQuery(client.queryOptions(ledger, "icrc1_fee"))
  return <span>{fee.data.toString()}</span>
}

export function FeeBox({ id }: { id: string }) {
  return (
    <Suspense fallback={<span>…</span>}>
      <Fee id={id} />
    </Suspense>
  )
}
```

A suspense read cannot wait for its variables: `useSuspenseQuery` refuses
options built with `skipToken` or with variables typed `V | SkipToken` (see
[Reads](https://ic-reactor.b3pay.net/v4/guides/reads/#suspense)).

## See also

- [Next.js SSR example](https://ic-reactor.b3pay.net/v4/examples/next-ssr/): every rule above, with tests.
- [The client](https://ic-reactor.b3pay.net/v4/guides/client/): `network`, `identity` and `auth`, `dispose`.
- [Reads](https://ic-reactor.b3pay.net/v4/guides/reads/): `queryOptions`, keys, retries and `skipToken`.
- [Auth](https://ic-reactor.b3pay.net/v4/guides/auth/): `useAuth()` and what changes with the caller.
- [Errors](https://ic-reactor.b3pay.net/v4/guides/errors/): `isReactorError` and the kinds.
- [@ic-reactor/react](https://ic-reactor.b3pay.net/v4/packages/react/): the provider and the hooks.