Skip to content
IC Reactor

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

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:

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 layout is a Server Component. It renders a client module that holds the provider:

src/app/providers.tsx
"use client"
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>
)
}
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.

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.

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.

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:

src/components/LedgerCard.tsx
"use client"
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>
)
}

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.

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.

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.

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

Section titled “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:

"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

Section titled “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.

"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>
}
// 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

Section titled “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:

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

Section titled “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.
"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).