Optimize Server-Side Rendering (SSR) for Fullstack Frameworks
This guide demonstrates an optimized approach for setting up Server-Side Rendering (SSR) with oRPC in fullstack frameworks like Next.js, Nuxt, and SvelteKit. This method enhances performance by eliminating redundant network calls during the server rendering process.
The Problem with Standard SSR Data Fetching
In a typical SSR setup within fullstack frameworks, data fetching often involves the server making an HTTP request back to its own API endpoints.
This pattern works, but it introduces unnecessary overhead: the server needs to make an HTTP request to itself to fetch the data, which can add extra latency and consume resources.
Ideally, during SSR, the server should fetch data by directly invoking the relevant API logic within the same process.
Fortunately, oRPC provides both a server-side client and client-side client, so you can leverage the former during SSR and automatically fall back to the latter in the browser.
Conceptual approach
// Use this for server-side calls
const orpc = createRouterClient(router)
// Fallback to this for client-side calls
const orpc: RouterClient<typeof router> = createORPCClient(someLink)
But how? A naive typeof window === 'undefined'
check works, but exposes your router logic to the client. We need a hack that ensures server‑only code never reaches the browser.
Implementation
We’ll use globalThis
to share the server client without bundling it into client code.
import type { RouterClient } from '@orpc/server'
import { RPCLink } from '@orpc/client/fetch'
import { createORPCClient } from '@orpc/client'
declare global {
var $client: RouterClient<typeof router> | undefined
}
const link = new RPCLink({
url: new URL('/rpc', typeof window !== 'undefined' ? window.location.href : 'http://localhost:3000'),
})
/**
* Fallback to client-side client if server-side client is not available.
*/
export const client: RouterClient<typeof router> = globalThis.$client ?? createORPCClient(link)
'server only'
import { createRouterClient } from '@orpc/server'
globalThis.$client = createRouterClient(router, {
/**
* Provide initial context if needed.
*
* Because this client instance is shared across all requests,
* only include context that's safe to reuse globally.
* For per-request context, use middleware context or pass a function as the initial context.
*/
context: async () => ({
headers: await headers(),
}),
})
Finally, import lib/orpc.server.ts
before anything else and on the server only. For example, in Next.js add it to app/layout.tsx
:
import '@/lib/orpc.server'
// Rest of the code
Now, importing client
from lib/orpc.ts
gives you a server-side client during SSR and a client-side client on the client without leaking your router logic.
Using the client
The client
requires no special handling, just use it like regular clients.
export default async function PlanetListPage() {
const planets = await client.planet.list({ limit: 10 })
return (
<div>
{planets.map(planet => (
<div key={planet.id}>{planet.name}</div>
))}
</div>
)
}
INFO
This example uses Next.js, but you can apply the same pattern in SvelteKit, Nuxt, or any framework.
TanStack Query
Combining this oRPC setup with TanStack Query (React Query, Solid Query, etc.) provides a powerful pattern for data fetching, and state management, especially with Suspense hooks. Refer to these details in Tanstack Query Integration Guide and Tanstack Query SSR Guide.
export default function PlanetListPage() {
const { data: planets } = useSuspenseQuery(
orpc.planet.list.queryOptions({
input: { limit: 10 },
}),
)
return (
<div>
{planets.map(planet => (
<div key={planet.id}>{planet.name}</div>
))}
</div>
)
}
WARNING
Above example uses suspense hooks, you might need to wrap your app within <Suspense />
(or corresponding APIs) to make it work. In Next.js, maybe you need create loading.tsx
.