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:
- One client per request. Never build the client at module scope on a server: a module-scope client is shared by every request.
- The provider’s factory lives in a
"use client"module. A function cannot cross from a Server Component into a client component. - A server renders; it does not run. The auth factory is never called,
nothing reads
windoworlocalStorage, and no effect runs. - The server is anonymous. It prefetches as the anonymous principal, and the browser’s hydrating render uses the same principal, so the keys match.
- A read through
queryOptionsis never retried on a server. Nor is any read of the client’sQueryClient. A direct call still re-sends a retryable read up to twice.
One client per request
Section titled “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:
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
Section titled “The provider”The layout is a Server Component. It renders a client module that holds the provider:
"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: (network) => new AuthClient(network), }) } > {children} </ReactorProvider> )}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
Section titled “Which network”The network option is part of every query key. On a server:
"ic"and"local"work as they do anywhere."env"has noic_envcookie to read on a server. A{ id }target works. A{ name }target never resolves: every call rejects asinvalid_argswith codecanister_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_HOSTorIC_HOSTwhenICP_NETWORKis"local"(icp-cli’shttp://127.0.0.1:8000if neither host is set), and mainnet otherwise. With noICP_NETWORK, dfx’sDFX_NETWORK=localcounts too, and its default is dfx’shttp://127.0.0.1:4943.
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
Section titled “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.
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> )}prefetchQuerynever throws. A failed read stays in the cache with its error, and you read it back withgetQueryState.dehydrate(client.queryClient)is plain JSON as it is. The client’sQueryClientrewritesbigint,Uint8Array,NaN,Infinity,-Infinity,-0andundefinedinto tagged JSON when it dehydrates, and reads them back exact when it hydrates. Amounts arrive asbigint, not as rounded numbers.- TanStack dehydrates successful reads only. A failed read stays on the
server, so render its error on the server, where
isReactorErrorcan read itskind. Anerror.tsxboundary 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 buildwould call mainnet while it prerenders the page, and keep a stale answer.
The component that reads the data builds the same options in render:
"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> )}The hydrating render
Section titled “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 (anAuthClientreads a stored one synchronously) it returns the anonymous caller’s view of the client, as it returns the view of whoever each later render shows. ItsqueryKey,queryOptions,caller()andauthState()are the anonymous caller’s, so the page finds what the server dehydrated and matches the server’s HTML, and itsmutationOptionssend only as that caller, and only while it is the caller current whenmutate()is called. The canisters,signIn,signOutand theQueryClientare the client’s own.- 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.
Streaming a slow section
Section titled “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.
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
isReactorErrorreads itskindinto 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
Section titled “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
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}What useClient() returns is a view for the caller its render shows (the
anonymous one while a page hydrates), and a view never moves on: the hook
returns another one when the caller changes. Kept from one render anywhere
else (useState(client), useRef(client), a useMemo or useCallback with
[], a module variable), it stays that view:
- the component shows that caller’s data;
- a read it starts while someone else is signed in is cancelled
(
caller_changed); - a write through its
mutationOptionsis refused (caller_changed) and sends nothing; - 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 Componentimport { 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
cancelledwith codecaller_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).
See also
Section titled “See also”- Render a server page for one user: one account’s page, step by step, in a new Next.js app with Cache Components.
- Next.js SSR example: every rule above, with tests.
- The client:
network,identityandauth,dispose. - Reads:
queryOptions, keys, retries andskipToken. - Auth:
useAuth()and what changes with the caller. - Errors:
isReactorErrorand the kinds. - @ic-reactor/react: the provider and the hooks.