Back

oRPC

ORPC is a friendly library to help us build an end-to-end, type-safe API. Let me explain.


Before

Generally, an API server exposes endpoints regardless of the backend language. The backend team—or whoever maintains it—may write documentation explaining how frontend developers can use it, including methods, endpoints, payloads, request bodies, headers, and so on.

And your frontend code may look something like this:

try {
  const res = await fetch('https://api.example.com/planets', {
    method: 'GET',
  })

  if (!res.ok) {
    throw new Error(/* error */)

    // or maybe :
    const text = await res.text()
    throw new Error(text)
  }

  const json = await res.json()
  const parsed = z.object({ name: z.string() }).array().parse(json)

  return parsed // <-- typed response
} catch (error) {
  if (error instanceof Error) {
    if (error.message === 'INVALID_BODY') {
      // ....
    }
    if (error.message === 'INVALID_HEADER') {
      // ....
    }
  }

  // ... handle unexpected errors
}

With oRPC, we can simplify this code to:

const { error, data } = await safe(
  apiClient.auth.listPlanets({
    name: 'Earth',
    workspaceId: '1',
  }),
)

if (error) {
  if (isDefinedError(error)) {
    // error.code is typed
    switch (error.code) {
      case 'RETRY_AFTER':
        console.log(`Retry after ${error.data.seconds} seconds`)
        break
      default:
        break
    }
  } else {
    // ... handle unexpected errors
  }
} else {
  return data
}

we don't need to guess! everything is typed. From query, params, body, etc... And thats what I meant for end-to-end, to more custom validations.

How do we set it up

Generally, we will need three components:

  • server
  • handler
  • client

Installation

npm install @orpc/client@beta
npm install @orpc/contract@beta
npm install @orpc/openapi@beta
npm install @orpc/server@beta

Handler

import { openapi } from '@orpc/openapi'
import { os } from '@orpc/server'
import { z } from 'zod'
import { OpenAPIHandler } from '@orpc/openapi/fetch'

const inputSchema = z.object({
  page: z.number(),
  size: z.number(),
})

const outputSchema = z
  .object({
    id: z.string(),
    name: z.string(),
    createdAt: z.coerce.date<Date>(), // <--- the `coerce` here is important if you are using openapi modules
  })
  .array()

export const router = {
  planet: {
    list: os
      .meta(openapi({ method: 'GET', path: '/planets' }))
      .input(inputSchema)
      .output(outputSchema)
      .handler(async ({ input }) => {
        const { page, size } = input
        const data = await db
          .select()
          .from(planets)
          .offset(page * size)
          .limit(size)
        return data
      }),
  },
}

export const handler = new OpenAPIHandler(router)

The simplest version of handler look something like this. And the code is pretty self explanatory.

Server

Now we need to connect the handler to the server.

import { handler } from './orpc'

Bun.serve({
  async fetch(request: Request) {
    const { matched, response } = await handler.handle(request, {
      context: {},
    })

    if (matched) {
      return response
    }

    return new Response('Not found', { status: 404 })
  },
})

Type-safe API Client

import { createORPCClient } from '@orpc/client'
import { ResponseValidationLinkPlugin } from '@orpc/contract/plugins'
import { OpenAPILink } from '@orpc/openapi/fetch'
import { type RouterClient } from '@orpc/server'

const link = new OpenAPILink(router, {
  origin: 'http://localhost:3000',
  interceptors: [
    async ({ next, path }) => {
      console.time(path.join('.'))
      try {
        return await next()
      } finally {
        console.timeEnd(path.join('.'))
      }
    },
  ],
  fetch: (request, init) => {
    return globalThis.fetch(request, {
      ...init,
    })
  },
  plugins: [new ResponseValidationLinkPlugin(router)],
})

export const apiClient: RouterClient<typeof router> = createORPCClient(link)

Remember to use ResponseValidationLinkPlugin with OpenAPI. Without it, dates are returned as strings at runtime. The plugin validates the response and coerces those strings back into Date objects.

Summary

So far, oRPC has become my go-to library for building full-stack applications. It supports runtimes beyond Bun, including Node.js and Cloudflare Workers. It also integrates with TanStack Query, so we don’t need to build query options ourselves. Give it a try if you haven’t already.

If you enjoy TanStack Start’s server functions, tRPC, Elysia Treaty, or Hono RPC, you’ll likely enjoy oRPC as well.