React Server Components
Prefetch on the server, hydrate into the client cache — no loading flash, no waterfall, and server-rendered rows a client mutation still patches by identity.
result-rpc renders on the server the way it renders anywhere: prefetch queries into a runtime, dehydrate that runtime to a plain serializable value, and let a client boundary merge it. The payoff is the full one — not just a filled cache, but entities that are indexed on hydrate, so a client mutation patches a server-rendered row in place with no refetch.
The shape
Three steps, each a value crossing one boundary:
- Server: a per-request runtime over an in-process server client prefetches queries.
- Boundary:
runtime.dehydrate()returns{ v, serializer, contract, payload }— a plain object with a string payload, so it crosses the RSC server→client boundary as an ordinary prop. - Client:
<ResultRpcHydrationBoundary state={...}>merges it into the one client runtime, during render, before the first paint reads the cache.
// app/rsc.ts — one runtime per request, shared by every server component
import { cache } from "react";
import { createServerClient } from "result-rpc/server";
import { createQueryRuntime } from "result-rpc/query"; // ← react-free entry
import { appRouter } from "@app/server"; // server-only module
export const getServerRpc = cache(() => {
const client = createServerClient(appRouter, { context: buildContext() });
return { client, runtime: createQueryRuntime({ client }) };
});
Two entries:
result-rpc/queryon the server,result-rpc/reactin components.result-rpc/reactis marked"use client". Rendering its components from a server component is fine and expected —<ResultRpcHydrationBoundary>below is imported straight into a server component, and the bundler turns it into a client reference rather than executing it on the server. That is the boundary working as designed.The cache runtime has no React dependency, so it ships as its own entry.
createQueryRuntimeis intentionally not exported fromresult-rpc/react: importing it from a"use client"entry can compile yet fail when a React server evaluates the call. Import it and its types fromresult-rpc/queryin server code, where the mistake is caught at the module boundary instead of on a production request.
cache() (React’s per-request memo) means every server component in one request
shares a runtime — prefetches accumulate, and you dehydrate once at the boundary.
// app/users/[id]/page.tsx — a server component
import { getServerRpc } from "@app/rsc";
import { ResultRpcHydrationBoundary } from "result-rpc/react";
import { UserDetail } from "./user-detail"; // a client component
export default async function Page({ params }: { params: { id: string } }) {
const { client, runtime } = getServerRpc();
await runtime.prefetch(client.users.get, { id: params.id });
return (
<ResultRpcHydrationBoundary state={runtime.dehydrate()}>
<UserDetail id={params.id} />
</ResultRpcHydrationBoundary>
);
}
// user-detail.tsx — a client component
"use client";
import { useResultQuery } from "result-rpc/react";
import { client } from "@app/client";
export function UserDetail({ id }: { id: string }) {
// Already `success` on first paint — the server prefetched it.
const user = useResultQuery(client.users.get, { id }, { staleTime: 60_000 });
if (user.state !== "success") return <UserSkeleton />;
return <h1>{user.value.name}</h1>;
}
The ResultRpcProvider still wraps your app once (usually in the root layout’s
client boundary), owning the client runtime. The hydration boundary feeds it.
Set a staleTime on prefetched queries
Server-prefetched data arrives with the timestamp it was fetched. With the
default staleTime of 0 it is immediately stale, so the client revalidates in
the background on mount — correct, but a wasted round-trip when you just rendered
it. Give prefetched queries a staleTime so the server data is trusted for a
window and the first mount makes zero client requests — no loading flash
and no immediate refetch.
This is browser query freshness, not an HTTP or CDN cache directive. See Freshness, fetch state, and backend caching for the canonical distinction.
Nested boundaries merge
Unlike the provider’s one-shot hydrate prop, boundaries are nestable — the
App Router idiom. Each route segment’s server component prefetches only what it
owns and renders its own boundary; every payload merges into the one client
runtime:
<ResultRpcHydrationBoundary state={layoutRuntime.dehydrate()}>
<Nav />
<ResultRpcHydrationBoundary state={pageRuntime.dehydrate()}>
<Content />
</ResultRpcHydrationBoundary>
</ResultRpcHydrationBoundary>
The entity payoff
Hydrated queries are indexed in the entity cache exactly
as fetched ones are — the dehydrated data is re-decoded through each output
codec on hydrate, which re-brands its entities. So a server-rendered user row
and a client mutation that returns that same user entity are connected: the
mutation patches the row in place, at one request, with no refetch — even though
no client component ever fetched it. Server rendering does not cost you the
live-update behavior; it seeds it.
Deploy skew is survived, not crashed
If the server and client bundles briefly disagree on the serializer or contract version across a deploy, the boundary skips hydration (with a dev warning) and the client fetches fresh, rather than throwing during render and taking down the tree. A stale server payload never renders as if it were current.
Calling procedures on the server
createServerClient runs procedures in-process. Use it for server rendering,
server actions, and background jobs. It keeps everything that decides whether a
call is correct — the middleware chain and its context, input validation,
output encode/decode (which also brands entities), and the sanitization of
private errors into server/internal — and drops only the transport: no
serializer round trip, no HTTP envelope, no contract digest, no retry, no
batching.
Wire-faithful tests use createParityClient from result-rpc/testing instead.
That client crosses the serializer, protocol envelope, fetch handler, and
browser decoder so tests prove a value survives the trip.
The constructors also expose different error types:
// createParityClient / createBrowserClient
DocNotFound |
Unauthorized |
ServerInternal |
ServerBadRequest |
Offline |
NetworkFailure |
Timeout |
HttpFailure |
ProtocolViolation |
DecodeFailure |
Stale;
// createServerClient — what is actually reachable in-process
DocNotFound | Unauthorized | ServerInternal | ServerBadRequest;
The client-boundary tags are gone because they are unreachable, not because
they were hidden: there is no socket to drop, no navigator to report offline,
and no second build to drift from. That narrowing is what makes an exhaustive
matchError in a server component three arms instead of a dozen.
Shells do not exist on the server
result-rpc/react is a client entry, and claiming is React context. A server
component can render <ResultRpcHydrationBoundary> as a client reference, but
it cannot use a shell — and what shells do (pause and resume on reconnect,
redirect to login, hold and drain) only means something in a live browser.
So a server component sees the complete union and handles it itself. You have three honest options:
- Ignore the failure. Legitimate, and usually right. A declared domain failure hydrates (see below), so the browser paints it without asking. A transport failure does not, so the client mounts that query cold, fetches once, and the shells own the failure there — live, and retryable.
- Handle the outcomes that belong to the response. Some failures deserve a
server answer:
notFound()ondoc/not-found,redirect("/login")on session expiry, a 500 page onserver/internal. These are framework primitives, and they beat a client shell — right status code, no flash, no wasted round trip. - Do not try to reproduce shells. Pause/resume, offline banners, and session-expiry-with-return-to need a client.
The rule of thumb: server components own the failures that change the HTTP response; shells own the failures that change the UI.
Mutations from server actions lose the cache. A mutation called through a direct caller executes normally and returns its
Result, but its cache declarations are inert —.affects(), entity patching, andtouchare client-runtime behaviors, and there is no client cache on a server. The write lands; the browser’s cache learns nothing about it. Refresh the route, or perform the mutation from the client where the machinery lives.
Declared failures hydrate; transport failures do not
The split follows from the library’s first pillar: a declared domain error is
a value, and dehydrate() carries values.
doc/not-found is not a failed prefetch. It is the answer — the same answer the
client would get, arrived at on the server, and it hydrates like any success. A
detail page for a row that does not exist renders “not found” on first paint,
at zero client requests. It comes back reified through the procedure’s error
registry, so it narrows, matches, and is claimed by a
shell exactly as a live failure would be.
client/network-failure is the opposite in kind. It describes one attempt on
one machine — the server’s — and baking it into the payload would replace a
fetch the browser can retry with a verdict it cannot. So framework and transport
failures are still left out, and that query mounts cold and fetches once.
The rule is the tag: every error you declared on the procedure hydrates; the
built-in client/* and server/* failures do not.
Two details worth knowing if you inspect a payload. A retry detail about one
attempt (fetchFailureReason) is dropped rather than translated — it is
bookkeeping about a machine that is now gone. And the error crosses as its wire
form, the same shape the transport would have sent, because the serializer
rejects class instances by design; it is reconstructed on the way back in.
Pass values through component props, not Results
The hydration boundary above is a result-rpc boundary: it owns a versioned codec
and knows how to reconstruct supported runtime values. An ordinary RSC prop or
Next server-action/RPC payload does not. Do not pass a Result or TaggedError
through one and expect its iterator, definition identity, or Error prototype to
survive.
Unwrap at the server/client component boundary:
const result = await serverClient.users.get({ id });
if (result.status === "error") return <MissingUser id={id} />;
return <UserDetail initialUser={result.value} />;
When the browser needs the full Result API, let the result-rpc client make the
call or use runtime.dehydrate() for successful query data. The library promises
faithful runtime behavior across its own wire, not arbitrary serialization.
Per-framework mounting
The pattern is identical everywhere; only the mount point and the prefetch site move. Three worked examples ship in the repo:
| Example | Framework | Prefetch happens in | RPC handler mounts at |
|---|---|---|---|
09-waku |
Waku (RSC) | async server component | src/pages/_api/rpc.ts → /rpc |
10-nextjs |
Next.js App Router (RSC) | async server component | app/api/rpc/route.ts → /api/rpc |
11-tanstack-start |
TanStack Start (SSR) | route loader | src/routes/api.rpc.ts → /api/rpc |
Three traps worth knowing before you wire your own:
- Match the endpoint on both ends. The library defaults to
/rpc, but Next.js and TanStack Start mount route handlers under/api/. Set bothendpointoncreateFetchHandlerandurlonfetchTransport, or every call 404s silently. - TanStack Start reserves
src/server.tsandsrc/client.ts. It resolves those paths as its own optional entries, so the file names used by every other result-rpc example silently hijack them (the symptom is an opaqueCannot read properties of undefined (reading 'fetch')). Name themrpc-server.ts/rpc-client.tsthere. - In TanStack Start, loaders are isomorphic — that is a client-boundary
hazard. A loader that imports your database module directly typechecks and
works in dev SSR, then ships your database to the browser on the first client
navigation. There is no
'use client'directive to protect you here: put the server wall at acreateServerFn, and re-read The client boundary.
All three exercise the same app end to end — prefetched paginated feeds, skeleton fallbacks, mutations patching server-rendered rows, and a db-result constraint surfacing as a domain error — so you can diff them to see exactly what the framework changes and what it doesn’t.