Next.js App Router
This example demonstrates a hydration-safe IC Reactor setup in a Next.js App
Router application. createReactorProvider builds the client-side reactor, its
QueryClient and its AuthenticationManager once per render tree, so a server
render never shares a cache or an identity with another request, and one
ClientManager serves both sign-in and the ledger queries.
Features Demonstrated
Section titled “Features Demonstrated”- Next.js App Router with a client provider from
createReactorProvider - A
QueryClientper render tree, on the server and in the browser - Internet Identity authentication through the provider’s
useAuth - Dynamic ICRC ledger queries against live mainnet canisters, switched with
callConfig: { canisterId } - A balance query that waits for a valid account with
skipToken - A server component that reads the ledger per request with
ClientManagerandReactorimported from@ic-reactor/react
Key Code
Section titled “Key Code”"use client"import { QueryClient } from "@tanstack/react-query"import { createReactorProvider, defineReactor, reactorRetry,} from "@ic-reactor/react"import { idlFactory, canisterId } from "../declarations/ledger"import type { _SERVICE } from "../declarations/ledger"
export const { ReactorProvider: ICReactorProvider, useReactor: useLedger } = createReactorProvider(() => defineReactor<_SERVICE>({ name: "ledger", idlFactory, canisterId, agentOptions: { host: "https://ic0.app" }, queryClient: new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, retry: reactorRetry }, }, }), }) )page.tsx, a server component, renders <ICReactorProvider> around the page.
Client components take their hooks from useLedger(), typed as
defineReactor’s result. The token explorer keeps the selected ledger in state
and sends every query there through callConfig; each query key starts with
that canister, so every token has cache entries of its own:
"use client"import { useMemo, useState } from "react"import { useLedger } from "./providers"
const POPULAR_TOKENS = [ { name: "Internet Computer (ICP)", id: "ryjl3-tyaaa-aaaaa-aaaba-cai" }, { name: "Chain-Key BTC (ckBTC)", id: "mxzaz-hqaaa-aaaar-qaada-cai" }, // …]
export default function TokenExplorer() { const { useActorQuery, useUserPrincipal } = useLedger() const [currentCanisterId, setCanisterId] = useState(POPULAR_TOKENS[0].id) const callConfig = useMemo( () => ({ canisterId: currentCanisterId }), [currentCanisterId] )
const { data: symbol } = useActorQuery({ functionName: "icrc1_symbol", callConfig, }) // …}When the tree unmounts, the provider disposes the Internet Identity client its
AuthenticationManager built. See
createReactorProvider.
A server component imports the same package. Next.js resolves it through
@ic-reactor/react’s react-server export condition, which carries the core
classes and none of the hooks, and everything is built inside the request so
no cache is shared between visitors:
// src/app/LedgerSnapshot.tsx: a React Server Component (no "use client")import { connection } from "next/server"import { QueryClient } from "@tanstack/react-query"import { ClientManager, Reactor, formatTokenAmount } from "@ic-reactor/react"import { idlFactory, canisterId } from "../declarations/ledger"import type { _SERVICE } from "../declarations/ledger"
export default async function LedgerSnapshot() { await connection() // read per request, never at build time
const clientManager = new ClientManager({ queryClient: new QueryClient(), agentOptions: { host: "https://ic0.app" }, }) const ledger = new Reactor<_SERVICE>({ name: "ledger", clientManager, idlFactory, canisterId, })
const [symbol, decimals, totalSupply] = await Promise.all([ ledger.fetchQuery({ functionName: "icrc1_symbol" }), ledger.fetchQuery({ functionName: "icrc1_decimals" }), ledger.fetchQuery({ functionName: "icrc1_total_supply" }), ]) // Whole tokens, exact: the utilities import no React, so the react-server // entry exports them too. const supply = formatTokenAmount(totalSupply, decimals, { maxFractionDigits: 0, locale: "en-US", }) // …}The balance query waits for a valid account with skipToken, so it needs no
placeholder account, cast or enabled flag. The demo makes these queries inline
in TokenExplorer; here they are a component of their own:
// An excerpt of TokenExplorer (src/app/TokenExplorer.tsx), as a component of its ownimport { useMemo } from "react"import { formatTokenAmount, skipToken } from "@ic-reactor/react"import { Principal } from "@icp-sdk/core/principal"import { useLedger } from "./providers"import type { Account } from "../declarations/ledger"
// `activeAddress` and `callConfig` are the explorer's state from abovefunction Balance({ activeAddress, callConfig,}: { activeAddress: string callConfig: { canisterId: string }}) { const { useActorQuery } = useLedger()
const account = useMemo((): Account | undefined => { try { return activeAddress ? { owner: Principal.fromText(activeAddress), subaccount: [] } : undefined } catch { return undefined } }, [activeAddress])
const { data: decimals } = useActorQuery({ functionName: "icrc1_decimals", callConfig, }) const { data: balance } = useActorQuery({ functionName: "icrc1_balance_of", args: account ? [account] : skipToken, callConfig, })
// The balance is in base units; the token's decimals place the point return ( <span> {balance === undefined || decimals === undefined ? "…" : formatTokenAmount(balance, decimals)} </span> )}