Server rendering
SSR and hydration
How the server's query cache streams to the browser, pending queries included, and how the browser reconciles it with its own cache.
With server rendering, the queries a page needs run on the server, and the HTML arrives with the data already in it. raf-query streams the server's cache to the browser as entries start and settle, so the browser starts with the same data and doesn't fetch it again.
Your components never deal with this. A handle only carries a key and an input, and the browser's cache has the entry by the time anything reads it. There is no initialData to thread through props, and no hydration boundary to place around each component.
What happens on a request
- The server creates a query client for the request. Each request gets its own, so users never see each other's data.
- Loaders or server components call
load.swrandrevalidateentries wait for their data;prefetchentries don't. - React renders and streams HTML. Components reading a pending query suspend, and their Suspense fallbacks go out first.
- The cache streams alongside the HTML. Each entry is sent once, as soon as it starts or settles. Entries still pending cross as promises, which resolve in the browser when the server's request finishes.
- The browser hydrates. Its query client receives each entry. A pending entry resumes the server's request instead of starting a new one, so a query never runs twice.
- The request ends. The server's client is cleared and dropped.
Server clients
createQueryClient makes a server client where window doesn't exist (set isServer to choose). A server client renders once, so it behaves differently:
| Behavior | Browser | Server |
|---|---|---|
| Retries | With the retry() link | None, even with retry() |
gcTime | 5 minutes by default | Never collected: the cache lives as long as the request |
| Stale cached data | Refetched (swr entries resolve with it and refetch alongside) | Reused: staleTime doesn't apply within a request; revalidate entries refetch invalidated data |
| Focus and reconnect | Refetch read, stale queries, with their feature | Not watched: features start when a provider mounts |
So in a server render, every entry makes at most one request per query, whatever its staleTime, unless a write invalidates the query and a revalidate entry reads it again.
A server render fetches a query that nothing started, except where server components feed it: see Queries nothing started on the server.
The provider
Every framework provides the client with QueryClientProvider. One Providers component works in all of them: it takes the router's client when the router makes one, and otherwise creates its own, one per server render and one per browser tab:
"use client";
import { type interface QueryClient<TTypes extends RootTypes = any>A query cache. TTypes is its root's types, which factories built with
bindQueryOptions<typeof makeQueryClient>() are checked against. The bare
QueryClient type takes a client of any root.
QueryClient, function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient;
}): JSX.Element
Provides a client to the tree, and runs its features (focus and
reconnect refetching, polling, …) while mounted.
QueryClientProvider } from "@raf-query/react";
import { type type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefinedRepresents all of the things React can render.
ReactNode, function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.
useState } from "react";
import { function makeQueryClient(): QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
makeQueryClient } from "~/query-client.ts";
/**
* The app's query client: the router's when it makes one, or one created
* here, per server render and per browser tab.
*/
export function function Providers(props: {
readonly children: ReactNode;
readonly client?: QueryClient;
}): JSX.Element
The app's query client: the router's when it makes one, or one created
here, per server render and per browser tab.
Providers(props: {
readonly children: ReactNode;
readonly client?: QueryClient;
}
props: {
readonly children: ReactNodechildren: type ReactNode = string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | undefinedRepresents all of the things React can render.
ReactNode;
readonly client?: QueryClient<any> | undefinedclient?: interface QueryClient<TTypes extends RootTypes = any>A query cache. TTypes is its root's types, which factories built with
bindQueryOptions<typeof makeQueryClient>() are checked against. The bare
QueryClient type takes a client of any root.
QueryClient;
}) {
const [const client: QueryClient<any> | QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
client] = useState<QueryClient<any> | QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>>(initialState: QueryClient<any> | QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}> | (() => QueryClient<...> | QueryClient<...>)): [...] (+1 overload)
Returns a stateful value, and a function to update it.
useState(() => props: {
readonly children: ReactNode;
readonly client?: QueryClient;
}
props.client?: QueryClient<any> | undefinedclient ?? function makeQueryClient(): QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
makeQueryClient());
return (
<function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient;
}): JSX.Element
Provides a client to the tree, and runs its features (focus and
reconnect refetching, polling, …) while mounted.
QueryClientProvider client: QueryClient<any>client={const client: QueryClient<any> | QueryClient<{
readonly meta: Flat<Present<FocusRefetchMeta> & Present<ReconnectRefetchMeta> & Present<PollingMeta> & Present<RetryMeta>>;
}>
client}>{props: {
readonly children: ReactNode;
readonly client?: QueryClient;
}
props.children: ReactNodechildren}</function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient;
}): JSX.Element
Provides a client to the tree, and runs its features (focus and
reconnect refetching, polling, …) while mounted.
QueryClientProvider>
);
}| Framework | Renders |
|---|---|
| TanStack Start | <Providers client={queryClient}> in the router's Wrap, with the client loaders use |
| Next.js | <Providers> in the root layout. Server components use their own client per request |
| React Router | <Providers> in the root route. Loaders use the request's client from middleware |
"use client" makes it a client component in Next.js. Elsewhere the directive does nothing.
Queries nothing started on the server
A component can read a query that no loader or server component started: a waterfallHandle's query, or one the server's cache dropped after its loader ran. What the server render does with it depends on what carries the server's cache to the browser:
| Framework | A read of a query nothing started, on the server |
|---|---|
| TanStack Start | Fetches while rendering. setupRouterIntegration streams the entry with the rest of the cache, so the browser resumes it instead of fetching again |
| React Router | Fetches while rendering, so the HTML has the data. A loader's snapshot carries only what loaders started, so the browser fetches the query again |
| Next.js and other RSC frameworks | Renders the nearest Suspense fallback, and the query loads in the browser |
With server components, the client components' server render has nothing to send its own requests through: only server components' HydrationBoundary sends entries. So that HydrationBoundary marks the client components' server render as it renders. Below it, a read of a query nothing sent doesn't start the request: its read rejects, use() throws, and React renders the nearest Suspense fallback in the HTML. The browser renders that boundary again, and its read starts the query there. Put a Suspense boundary around such readers, or start the query in a server component to have it in the HTML.
React sees that read as an error it recovers from. The error has the digest Next.js uses for ssr: false, so Next.js reports nothing. Other RSC frameworks report it to the server render's onError and, while hydrating, the browser's onRecoverableError.
The mark is set when the first HydrationBoundary renders. A client component that renders before it, such as one in a root layout above the page's boundary, still fetches on the server, and the browser fetches that query again.
Errors
The stream sends entries with data or with a request in flight. A query that had already failed when its entry would have been sent stays on the server, and the browser fetches it again when something reads it.
A query sent while pending crosses as a promise. If it throws on the server, the promise rejects with a RedactedError that names the query but not the message, so server internals (SQL errors, stack traces) don't leak into the page. The browser treats it like any failed load: the reader throws to its error boundary, and resetting the boundary with resetOne refetches the query from the browser. Pass keepErrors: true in the integration's dehydrateOptions to send the original error instead.
Browser-only queries
Some data only the browser can know: a push subscription, a notification permission, a service worker, a media query. That still belongs in the cache, with the same factory, handle and invalidation. Its factory is an ordinary one:
export function function pushStateQuery(input: {
channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery(input: {
channel: string;
}
input: { channel: stringchannel: string }) {
return queryOptions<readonly ["pushState", string], "on" | "off", "on" | "off">(options: BaseOptions<readonly ["pushState", string], DefaultTypes> & {
readonly queryFn: (context: QueryFunctionContext<readonly ["pushState", string]>) => Awaitable<"on" | "off">;
readonly select?: ((data: "on" | "off") => "on" | "off") | undefined;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes> (+1 overload)
Typed options for a query factory:
queryOptions({
BaseOptions<readonly ["pushState", string], DefaultTypes>.queryKey: readonly ["pushState", string]A plain array, usually built by a group(). Handles keep its literal type.
queryKey: const pushState: Group
<readonly [string]>(rest_0: string) => readonly ["pushState", string]
pushState(input: {
channel: string;
}
input.channel: stringchannel),
queryFn: (context: QueryFunctionContext<readonly ["pushState", string]>) => Awaitable<"on" | "off">queryFn: () => function detectPushState(channel: string): Promise<"on" | "off">detectPushState(input: {
channel: string;
}
input.channel: stringchannel),
});
}
What keeps it off the server is where its handle comes from. A loader also runs on the server, so it has no entry for the query. The component builds the handle in the browser, after hydration, with waterfallHandle, and reading it starts the request:
function function PushSettings(): React.JSX.ElementPushSettings() {
// `false` on the server and while hydrating, `true` after.
const const hydrated: booleanhydrated = function useHydrated(): booleanReturn a boolean indicating if the JS has been hydrated already.
When doing Server-Side Rendering, the result will always be false.
When doing Client-Side Rendering, the result will always be false on the
first render and true from then on. Even if a new component renders it will
always start with true.
useHydrated();
if (!const hydrated: booleanhydrated) {
return <function PushSwitch(props: {
readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch state: "unknown" | "on" | "off"state="unknown" />;
}
return (
<const Suspense: React.ExoticComponent<React.SuspenseProps>Lets you display a fallback until its children have finished loading.
Suspense React.SuspenseProps.fallback?: ReactNodeA fallback react tree to show when a Suspense child (like React.lazy) suspends
fallback={<function PushSwitch(props: {
readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch state: "unknown" | "on" | "off"state="unknown" />}>
<function PushState(): React.JSX.ElementPushState />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Lets you display a fallback until its children have finished loading.
Suspense>
);
}
function function PushState(): React.JSX.ElementPushState() {
const [const handle: QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
handle] = useState<QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>>(initialState: QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>> | (() => QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>)): [...] (+1 overload)
Returns a stateful value, and a function to update it.
useState(() =>
waterfallHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>(factory: (input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>, input: {
channel: string;
}): QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>> (+1 overload)
The handle of a query whose input is only known while rendering, like an
id in another query's data. It starts nothing: the component that reads
it fetches when it renders, which makes it a request waterfall, and the
name keeps that visible. Prefer starting queries in a loader with load.
waterfallHandle(function pushStateQuery(input: {
channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery, { channel: stringchannel: "news" }),
);
const [const pushThenable: QueryValue<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
pushThenable] = useQuery<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>(factory: (input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>, handle: QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>, options?: UseQueryOptions): readonly [...]
Read a query by its handle.
useQuery(function pushStateQuery(input: {
channel: string;
}): QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>
pushStateQuery, const handle: QueryHandle<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
handle);
const const push: "on" | "off"push = use<"on" | "off">(usable: React.Usable<"on" | "off">): "on" | "off"use(const pushThenable: QueryValue<(input: {
channel: string;
}) => QueryOptions<readonly ["pushState", string], "on" | "off", "on" | "off", DefaultTypes>>
pushThenable);
return <function PushSwitch(props: {
readonly state: "on" | "off" | "unknown";
}): ReactNode
PushSwitch state: "unknown" | "on" | "off"state={const push: "on" | "off"push} />;
}
- TanStack Router's
useHydrated()isfalseon the server and while the page hydrates, andtrueafter. Until then, render what the server renders, so hydration matches: here, the switch with no state. useQuerycan't be called conditionally, so the query is read in a child component that renders only once hydrated.- The child builds the handle once, in
useState's initializer, so every render reads the same handle. - The server never sees the handle, so it never runs the
queryFnor sends the query. On a client-side navigation,useHydrated()istruefrom the first render, and the query starts as the component mounts.
When the browser already has data
On client-side navigations in Next.js, server components render again and send their cache entries with the RSC payload, and React Router's loaders return a new snapshot, while the browser may already have the same entries. The browser takes whichever copy is newer, by when it was fetched (updatedAt; server and browser clocks should agree):
- No data, no request of its own: it resumes the server's pending request.
- Data, or its own request in flight: it waits for the server's copy and takes it if newer.
- If it keeps its own copy (the server's is older, as in a payload the router replays from its cache on back and forward, or the server's request failed), it refetches it if a component reads it and it's stale.
Serialization
Data, keys and inputs cross the wire, so they must be serializable by the framework's serializer. TanStack Start uses Seroval, Next.js uses React's RSC serialization, and React Router uses turbo-stream: all three handle Date, Map, Set, BigInt and promises. meta stays on the server: the browser reads it from its own factories.
Only send what's serializable. These docs, for instance, cache each demo's code module as a query, and a module full of components can't cross the wire. The router integration's filter option keeps those entries out of the stream. A filter replaces the default one, canDehydrate (entries with data or a request in flight), so combine the two:
// Every demo query's key starts with this group.
const const demos: Group<readonly ["demos"]>demos = group<"demos">(name: "demos"): Group<readonly ["demos"]> (+1 overload)A group, or a subgroup inside parent. Call it for a query's key: a
flat, plain array that starts with the group's path. Invalidating a group
covers its subgroups and every query below them.
group("demos");
setupRouterIntegration<AnyRouter>(options: RouterIntegrationOptions<AnyRouter>): voidConnects a query client to a TanStack Router (and TanStack Start) router:
setupRouterIntegration({
RouterIntegrationOptions<TRouter extends AnyRouter>.dehydrateOptions?: DehydrateOptions | undefineddehydrateOptions: {
DehydrateOptions.filter?: ((entry: Entry) => boolean) | undefinedWhich entries to send. Default: entries with data or a request in
flight. An entry with neither has nothing to send and stays behind.
filter: (entry: Entryentry) => entry: Entryentry.Entry.key: QueryKeykey[0] !== const demos: Group<readonly ["demos"]>demos.Group<readonly ["demos"]>.path: readonly ["demos"]Its parents' names and its own.
path[0] && function canDehydrate(entry: Entry): booleanThe default filter: entries with data or a request in flight.
canDehydrate(entry: Entryentry),
},
RouterIntegrationOptions<TRouter extends AnyRouter>.queryClient: QueryClient<any>queryClient,
RouterIntegrationOptions<AnyRouter>.router: AnyRouterrouter,
});Integrations
- TanStack Start:
setupRouterIntegrationstreams the router's query client. - React Server Components:
HydrationBoundaryfor the Next.js App Router and other RSC frameworks. - React Router:
HydrationBoundaryfor the snapshot each Framework Mode loader returns.
Without an integration, @raf-query/react has the pieces they use: dehydrate(client) snapshots the cache, dehydrateStream(client) snapshots it and then streams entries as they start and settle, and hydrate(client, state) writes a snapshot into a client. HydrationBoundary takes a snapshot as its state prop and hydrates it while rendering.