Data Fetching
Fetch data with oRPC and TanStack Query: prefetch on the server, render with Suspense on the client
Init fetches data through oRPC + TanStack Query. The core pattern: prefetch on the server, render with suspense on the client. No loading spinners for first paint, no waterfall.
The Pattern
Two halves. The server component kicks off the query:
// apps/web/src/app/(dashboard)/dashboard/[slug]/(home)/page.tsx
import { Suspense } from "react";
import { HydrateClient, orpc, prefetch } from "@/orpc/server";
import { resolveOrganizationPage } from "../_components/organization-page";
// Reads the URL and the session, so it renders under Suspense.
const Todos = async ({ params }: PageProps) => {
const { slug } = await resolveOrganizationPage(params); // session check + slug
prefetch(orpc.todo.list.queryOptions({ input: { slug } }));
return (
<HydrateClient>
<TodoList slug={slug} />
</HydrateClient>
);
};
// Everything outside the boundary is the route's static App Shell.
const Page = ({ params }: PageProps) => (
<main>
<PageHeader>Todos</PageHeader>
<Suspense fallback={<TodoListSkeleton />}>
<Todos params={params} />
</Suspense>
</main>
);The client component consumes it:
// _components/todo-list.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { orpc } from "@/orpc/react";
export const TodoList = ({ slug }: { slug: string }) => {
const { data } = useSuspenseQuery(orpc.todo.list.queryOptions({ input: { slug } }));
// data is typed and already there - no isLoading branch
};How it works: prefetch starts the query in a server-side query client without awaiting it. HydrateClient dehydrates the (possibly still pending) promise into the RSC payload. useSuspenseQuery picks it up on the client and streams the result in. See apps/web/src/orpc/server.tsx.
The key: query options are shared. orpc.todo.list.queryOptions({ input: { slug } }) produces the same query key on server and client, so hydration matches up.
Rendering and Prefetching
apps/web/next.config.ts turns on cacheComponents and partialPrefetching. Every route is dynamic unless cached, and request data — cookies(), headers() (so getSession()), params, searchParams — must be read under a <Suspense> boundary or the build fails. What sits outside the boundaries is the route's App Shell: prerendered, and what a <Link> prefetches, shared by every link to that route.
- Pages stay synchronous and pass the
paramspromise into an async child under<Suspense>, as above. The dashboard layout does the same for the session-bound sidebar, so it never holds the page behind the session lookup. A redirect from inside a streamed boundary can only answer 200, sosrc/proxy.tssends a dashboard request without a session cookie to/auth/login?nextPath=…with a 307 first. That check is optimistic (the cookie exists, nothing more); each page'sgetSession()is the real gate. - Static content (docs, marketing) needs nothing: it prerenders. A dynamic route with
generateStaticParamsand no request data can exportensureStatic = "navigation"(as/docsdoes) so unknown params still answer a real 404. - Shared, non-personal data can be cached into the shell with
"use cache"pluscacheLife/cacheTag. Never cache per-user data with plain"use cache": it's shared across users. Dashboard data is per-user, so it streams per request through the oRPC prefetch instead. - Expensive secondary sections can start with
await navigation()(fromnext/cache) inside their<Suspense>so prefetches never run them. - Links keep the default prefetch.
prefetch={true}only pays off for a dynamic route whose URL-specific content is cached; for a large grid of links prefetch on intent (hover/focus) instead of on viewport.
Direct Server Calls
When data is only needed during server rendering - no client interactivity - skip hydration and call the procedure directly:
import { caller } from "@/orpc/server";
const { todos } = await caller.todo.list({ slug });caller invokes the router in-process (no HTTP), with context built from the request headers.
Which One?
| Situation | Use |
|---|---|
| Client component needs the data | prefetch + useSuspenseQuery |
| Server-only rendering, route handlers | caller |
| Data fetched on interaction (search-as-you-type) | useQuery, no prefetch |
Caching
Defaults live in apps/web/src/orpc/query-client.ts:
staleTime: 30_000- queries are fresh for 30s, so hydrated data isn't refetched on mount- Targeted invalidation - each mutation invalidates the queries it changed in its own
onSuccess; see Data Mutations - oRPC's
RPCSerializer- wired intodehydrate/hydrate, soDate,Map,Set,BigInt,URLreach the client as themselves instead of as strings
Query keys use TanStack's default hashing, which assumes plain-JSON inputs. Every procedure here takes one ({ slug }, an id, an email). Put a Date or a Set in a query input and you need a queryKeyHashFn that runs the key through RPCSerializer first — the default encodes a Set as {} and throws on BigInt.
Transport is RPCLink (apps/web/src/orpc/react.tsx), one /api/orpc request per call. Requests aren't batched: HTTP/2 multiplexes them already, and a batch is only as fast as its slowest procedure.
Query State Without Suspense
useSuspenseQuery throws to the nearest suspense/error boundary. For queries where you'd rather handle states inline:
const { data, isLoading, error } = useQuery(orpc.todo.list.queryOptions({ input: { slug } }));Prefer suspense for page-level data (it pairs with the server prefetch); use useQuery for secondary panels and interaction-driven fetches.
Typing Results
Don't redeclare API shapes - derive them:
import type { RouterOutputs } from "@repo/contract";
type Todo = RouterOutputs["todo"]["list"]["todos"][number];Debugging
onErrorinterceptor logs failed calls in development (apps/web/src/orpc/react.tsx)- TanStack Query Devtools render in the provider tree - inspect cache contents, query states, and invalidations live
Mobile
Same router, same hooks. apps/mobile/src/utils/api.ts wires the client to EXPO_PUBLIC_API_URL (dev builds derive it from Metro). No RSC on mobile, so there's no prefetch step - queries just fetch.
Next Steps
- Mutations - Writing data
- Full walkthrough - Build a CRUD feature
- API internals - oRPC architecture