Skip to content

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.

Standard SSR: Server calls its own API via HTTP.

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.

Optimized SSR: Server calls API logic directly.

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

ts
// 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.

ts
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)
ts
'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:

ts
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.

tsx
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.

tsx
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.

Released under the MIT License.