Tutorials / / 11 min read
Next.js SSR with oRPC and TanStack Query: From Zero to Live Queries
What We’re Building
A small “mission control” page in Next.js with three widgets, each showing a different kind of data flow:
- A planet list: a regular query, rendered on the server so the HTML arrives with data already in it.
- A discovery feed: a streamed query that appends events as the server pushes them.
- Live telemetry: a live query that always shows the latest reading, rendered on the server first and resumed in the browser.
Everything is typesafe end to end: the server defines plain TypeScript functions, and the client calls them with full autocompletion, no code generation, no shared type files to maintain. Three libraries make this work:
- oRPC turns your functions into an API and gives you a typed client.
- TanStack Query caches the results and manages loading, errors, and refetching.
@orpc/tanstack-queryglues them together: it builds query options from your oRPC client so you never write a query key or fetch function by hand.
Create the Project
You need Node.js 20.9 or newer. Scaffold a Next.js app and accept the defaults, which include TypeScript, the App Router, and the @/* import alias for imports from the project root:
npx create-next-app@latest mission-control
Then move into the project and install the oRPC and TanStack Query packages:
npm install @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodpnpm add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodyarn add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodbun add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodnub add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodaube add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta @tanstack/react-query @tanstack/react-query-next-experimental zodTwo of these deserve a word:
@tanstack/react-query-next-experimentalstreams server-rendered query data into the browser cache, so nothing is fetched twice. It is covered in the official Advanced SSR guide and removes all per-page prefetch boilerplate.- Zod validates procedure input at runtime. Any Standard Schema library works the same way.
Define Your API
An oRPC API is a router: a plain object of procedures, and a procedure is just a function built with the os builder. Create router.ts in the project root:
import { os } from '@orpc/server'
import * as z from 'zod'
const PLANETS = [
{ id: 1, name: 'Earth', description: 'Home, for now' },
{ id: 2, name: 'Mars', description: 'The backup plan' },
{ id: 3, name: 'Venus', description: 'Pretty from a distance' },
]
const listPlanets = os
.handler(async () => {
// replace with your database query
return PLANETS
})
const planetFeed = os
.handler(async function* ({ signal }) {
const names = ['Kepler-186f', 'TRAPPIST-1e', 'Proxima b', 'Gliese 581g']
for (let i = 0; ; i++) {
signal?.throwIfAborted()
yield { id: i, name: names[i % names.length], at: new Date() }
await new Promise(resolve => setTimeout(resolve, 2000))
}
})
const planetTelemetry = os
.input(z.object({ planet: z.string() }))
.handler(async function* ({ input, signal }) {
while (true) {
signal?.throwIfAborted()
yield {
planet: input.planet,
temperature: Math.round(15 + Math.random() * 10),
at: new Date(),
}
await new Promise(resolve => setTimeout(resolve, 1000))
}
})
export const router = {
planet: {
list: listPlanets,
feed: planetFeed,
telemetry: planetTelemetry,
},
}
The interesting part is the async generator functions (async function*). Every yield sends one event to the client over Server-Sent Events, and signal aborts the loop when the client disconnects. The client decides how to consume the stream: the feed will append events into a list, telemetry will replace the previous value.
Also notice at: new Date(). oRPC’s RPC protocol preserves types that plain JSON cannot represent, such as Date, Map, Set, and BigInt, so the client receives a real Date object.
Serve the Router
Next.js exposes HTTP endpoints through Route Handlers. A single catch-all handler serves the whole router. Create app/rpc/[[...rest]]/route.ts:
import { RPCHandler } from '@orpc/server/fetch'
import { router } from '@/router'
const handler = new RPCHandler(router)
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: '/rpc',
})
return response ?? new Response('Not found', { status: 404 })
}
export const HEAD = handleRequest
export const GET = handleRequest
export const POST = handleRequest
export const PUT = handleRequest
export const PATCH = handleRequest
export const DELETE = handleRequest
Every procedure is now reachable under /rpc, and you never think about this file again: new procedures added to the router are served automatically.
Create the Client
Now the other side: a typed client that calls those procedures, and the TanStack Query utilities built on top of it. Create lib/orpc.ts:
import type { RouterClient } from '@orpc/server'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import type { router } from '@/router'
const link = new RPCLink({
// In the browser, requests go to the page's own origin.
// During SSR there is no window, so point at the server itself.
origin: typeof window !== 'undefined' ? undefined : 'http://localhost:3000',
url: '/rpc', // <- must match the route handler's prefix
headers: async () => {
if (typeof window !== 'undefined') {
return {}
}
// During SSR, forward the incoming request's headers (cookies, auth, ...)
const { headers } = await import('next/headers')
return await headers()
},
})
export const client: RouterClient<typeof router> = createORPCClient(link)
export const orpc = createTanstackQueryUtils(client)
Three things happen here:
RPCLinksends procedure calls over HTTP to the route handler you just created.createORPCClientwraps the link in a client typed after your router.client.planet.list()is a fully typed function call.createTanstackQueryUtilswraps that client in TanStack Query helpers.orpc.planet.list.queryOptions()returns ready-made options, query key and fetch function included, foruseQueryand friends.
The same client works on the server and in the browser. The typeof window checks only pick the right origin and headers for each side.
A Query Client That Survives SSR
Here is the 30-second version of how TanStack Query SSR works: the server renders your components, runs their queries, and serializes the query cache into the HTML. The browser then hydrates that cache and takes over, without refetching anything. For the full picture, read the TanStack Query SSR guide.
That handoff needs a carefully configured QueryClient. Create lib/query-client.ts:
import { RPCJsonSerializer } from '@orpc/client'
import { createHydrationOptions } from '@orpc/tanstack-query'
import { environmentManager, QueryClient } from '@tanstack/react-query'
let browserQueryClient: QueryClient | undefined
/**
* A fresh query client per request on the server, a shared singleton in the browser.
*/
export function getQueryClient(): QueryClient {
if (environmentManager.isServer()) {
return createQueryClient()
}
browserQueryClient ??= createQueryClient()
return browserQueryClient
}
const serializer = new RPCJsonSerializer()
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount
},
...createHydrationOptions(serializer),
},
})
}
Each piece has one job:
getQueryClientcreates a fresh client for every server request, so one user’s data can never leak into another user’s HTML, while the browser reuses a singleton.createHydrationOptionsteaches the cache handoff about oRPC’s extra types. Dehydration normally goes through plain JSON, which would turn ourDateobjects into strings.RPCJsonSerializerround-trips them intact. See Custom Serializers to extend it with your own types.staleTime: 60 * 1000marks hydrated data as fresh, so the browser doesn’t immediately refetch what the server just rendered.
Wire Up the Providers
Make the query client available to your components. Create app/providers.tsx:
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
import { getQueryClient } from '@/lib/query-client'
export function Providers(props: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}
ReactQueryStreamedHydration is the piece that makes SSR effortless: any suspense query that runs during server rendering is automatically streamed into the browser’s cache, with no manual prefetchQuery or HydrationBoundary per page.
Then wrap your app with it in app/layout.tsx:
import { Providers } from './providers'
import './globals.css'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Setup done. Time to render some data.
Server-Rendered Data: The Planet List
Create components/planet-list.tsx:
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { orpc } from '@/lib/orpc'
export function PlanetList() {
const { data: planets } = useSuspenseQuery(orpc.planet.list.queryOptions())
return (
<ul>
{planets.map(planet => (
<li key={planet.id}>
<strong>{planet.name}</strong>
{' '}
{planet.description}
</li>
))}
</ul>
)
}
Note what you did not write: no query key, no fetch function, no response parsing, no result type. orpc.planet.list.queryOptions() derives all of it from the router, and planets is typed exactly as listPlanets returns.
useSuspenseQuery (rather than useQuery) is what opts this component into SSR: suspense queries execute during server rendering, while plain useQuery stays browser-only. Now replace app/page.tsx with:
import { Suspense } from 'react'
import { PlanetList } from '@/components/planet-list'
export default function Home() {
return (
<main>
<h1>Mission Control</h1>
<h2>Planets</h2>
<Suspense fallback={<p>Loading planets...</p>}>
<PlanetList />
</Suspense>
</main>
)
}
Start the dev server and open http://localhost:3000:
npm run dev
The planets are there, but the interesting proof is in View Page Source: Earth, Mars, and Venus appear in the raw HTML. The server ran the query and rendered the result. Check the network tab too: the browser never requests planet/list, because the hydrated cache already has it.
Streamed Queries: The Discovery Feed
Now the feed. Create components/discovery-feed.tsx:
'use client'
import { useQuery } from '@tanstack/react-query'
import { orpc } from '@/lib/orpc'
export function DiscoveryFeed() {
const { data: events = [] } = useQuery(orpc.planet.feed.streamedOptions({
queryFnOptions: { maxChunks: 10 }, // keep only the 10 newest events
retry: true, // reconnect forever if the stream drops
}))
if (events.length === 0) {
return <p>Scanning the sky...</p>
}
return (
<ul>
{events.map(event => (
<li key={event.id}>
Discovered
{' '}
<strong>{event.name}</strong>
{' '}
at
{' '}
{event.at.toISOString()}
</li>
))}
</ul>
)
}
.streamedOptions consumes the async generator and hands you the events as a growing array: every yield on the server appends one entry and re-renders the component. This component uses plain useQuery, so it skips SSR entirely and opens its stream in the browser, which is a perfectly fine choice for a feed that starts empty anyway.
Add it to the page:
<h2>Discovery Feed</h2>
<DiscoveryFeed />
Reload, and a new discovery blinks in every two seconds. Note event.at.toISOString(): that works because at is a real Date, delivered by the RPC protocol, not a string you have to re-parse.
Live Queries: Telemetry Rendered on the Server
The finale combines everything: a never-ending stream that still renders on the server. Create components/telemetry.tsx:
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { orpc } from '@/lib/orpc'
export function Telemetry() {
const { data: reading } = useSuspenseQuery(orpc.planet.telemetry.liveOptions({
input: { planet: 'earth' },
retry: true, // reconnect forever if the stream drops
}))
return (
<p>
{reading.planet}
{' '}
surface:
{' '}
<strong>
{reading.temperature}
°C
</strong>
{' '}
(updated
{' '}
{reading.at.toISOString()}
)
</p>
)
}
Where a streamed query accumulates events, .liveOptions keeps only the latest one: each yield replaces data. And since this is a useSuspenseQuery, it runs during SSR.
Wire it into the page, inside its own suspense boundary:
<h2>Telemetry</h2>
<Suspense fallback={<p>Connecting telemetry...</p>}>
<Telemetry />
</Suspense>
A query that never completes causes two SSR problems:
- On the server, waiting for the stream to end blocks rendering forever.
- In the browser,
staleTimemarks the hydrated reading fresh, so nothing reopens the stream. The widget freezes at the SSR value.
StreamQuerySSRUtilsPlugin fixes both. Register it once as a plugin in lib/orpc.ts:
import { StreamQuerySSRUtilsPlugin } from '@orpc/tanstack-query'
export const orpc = createTanstackQueryUtils(client, {
plugins: [new StreamQuerySSRUtilsPlugin()],
})
On the server, the plugin stops a live query at its first event (a streamed query once it connects) and closes the stream. The HTML ships with a real temperature. The plugin also sets refetchOnMount: 'always', so the browser reopens the stream on mount while the server value stays on screen. See Streamed and Live Queries for details.
Reload the page one last time and check your work:
- View Page Source contains a real temperature reading: the live query rendered on the server.
- The value keeps ticking every second in the browser: the client reopened the stream on mount.
- The network tab shows exactly one
planet/telemetryrequest and oneplanet/feedrequest after load, and still noplanet/listrequest.
That is the full loop: server-rendered HTML with live data in it, hydrated without duplicate fetches, and streams that resume where the server left off.
Where to Go Next
You now have the complete architecture: typed procedures, one catch-all route handler, TanStack Query utilities, and SSR that handles even infinite streams. Some directions to grow from here:
- Explore the Next.js playground, which extends this exact setup with mutations, infinite queries, file uploads, a multi-tab chat built on the Publisher helper, and OpenAPI generation. Open it in StackBlitz, or grab it locally:
npx giget gh:middleapi/orpc/playgrounds/next orpc-next-playground
- Skip HTTP during SSR with the in-process client from the Optimizing SSR guide.
- Add mutations and cache invalidation with the TanStack Query integration docs.
- Use server functions and form actions through the dedicated Next.js integration.
- Resume streams reliably after disconnects with event metadata and last event IDs.