Typesafe APIs,
made simple.
Write your API once. oRPC turns it into a fully typed client, a REST API with an OpenAPI document, and typed realtime streams. No code generation, no lock-in to a runtime.
What oRPC gives you
Define a procedure on the server, call it like a local function. No codegen, no drift.
import { os } from '@orpc/server'
import * as z from 'zod'
export const router = {
planet: {
find: os
.input(z.object({ id: z.number() }))
.handler(({ input }) => db.planets.get(input.id)),
// no output schema needed: the handler's return
// type flows straight to the client
},
}import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'
import type { router } from './router'
const link = new RPCLink({
origin: 'https://example.com',
url: '/rpc',
})
const client: RouterClient<typeof router>
= createORPCClient(link)
const planet = await client.planet.find({ id: 1 })
// ^ typed from the handler's return, nothing generatedFailures are part of the API. Declare them once, and the client knows every case.
import { os } from '@orpc/server'
import * as z from 'zod'
export const findPlanet = os
.input(z.object({ id: z.number() }))
.errors({
NOT_FOUND: { message: 'Planet not found' },
RATE_LIMIT: { data: z.object({ retryAfter: z.number() }) },
})
.handler(({ input, errors }) => {
const planet = db.planets.get(input.id)
if (!planet) {
throw errors.NOT_FOUND()
}
return planet
})import { isInferableError, safe } from '@orpc/client'
const [error, planet] = await safe(client.planet.find({ id: 1 }))
if (isInferableError(error) && error.code === 'RATE_LIMIT') {
error.data.retryAfter // number, straight from the schema
}
else if (error) {
// anything undeclared, still kept out of the success path
}
else {
planet // only reachable when there is no error
}Agree on the API before building it. The compiler keeps the server honest.
import { oc } from '@orpc/contract'
import * as z from 'zod'
export const contract = {
planet: {
find: oc
.input(z.object({ id: z.number() }))
.output(z.object({ id: z.number(), name: z.string() })),
},
}import { implement } from '@orpc/server'
import { contract } from './contract'
const os = implement(contract)
export const router = os.router({
planet: {
find: os.planet.find.handler(({ input }) =>
db.planets.get(input.id),
),
// return the wrong shape and this stops compiling
},
})Check auth once, not in every handler. Whatever middleware injects arrives fully typed.
import { ORPCError, os } from '@orpc/server'
export const authed = os
.use(async ({ context, next }) => {
const session = await auth()
if (!session) {
throw new ORPCError('UNAUTHORIZED')
}
return next({
context: { user: session.user }, // <- injected, typed
})
})import * as z from 'zod'
import { authed } from './middleware'
export const createPlanet = authed
.input(z.object({ name: z.string() }))
.handler(({ input, context }) => {
context.user // non-null, guarded by the middleware
return db.planets.create({
...input,
ownerId: context.user.id,
})
})Your router is also a REST API. The OpenAPI document comes free.
import { OpenAPIGenerator, openapi } from '@orpc/openapi'
import { os } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import * as z from 'zod'
export const router = {
planet: {
find: os
.meta(openapi({ method: 'GET', path: '/planets/{id}' }))
.input(z.object({ id: z.number() }))
.output(z.object({ id: z.number(), name: z.string() }))
.handler(({ input }) => db.planets.get(input.id)),
},
}
const spec = await new OpenAPIGenerator({
converters: [new ZodToJsonSchemaConverter()],
}).generate(router, {
base: { info: { title: 'Planets', version: '1.0.0' } },
})GET /planets/1 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{ "id": 1, "name": "Earth" }
# the same procedure the typed client calls, served as a
# REST endpoint and described by the generated documentReturn a Date, get a Date. Send a File, get a File. The wire format is oRPCβs problem.
import { os } from '@orpc/server'
import * as z from 'zod'
export const planet = {
stats: os.handler(() => ({
discoveredAt: new Date('1846-09-23'),
distanceKm: 4_500_000_000n,
moons: new Set(['Triton', 'Nereid']),
})),
uploadAvatar: os
.input(z.object({ image: z.file() }))
.handler(({ input }) => resize(input.image, 128)),
}const stats = await client.planet.stats()
stats.discoveredAt.getFullYear() // a real Date
stats.distanceKm + 100n // a real BigInt
stats.moons.has('Triton') // a real Set
const thumbnail = await client.planet.uploadAvatar({
image: fileInput.files[0], // a File goes up
})
thumbnail.name // a File comes back
// nothing stringified, no FormData, no base64Stream events with a plain yield. Add the Retry plugin and clients resume from the last event.
import { os, withEventMeta } from '@orpc/server'
export const liveUpdates = os
.handler(async function* ({ signal, lastEventId }) {
const updates = db.planets.watch({
since: lastEventId,
signal,
})
for await (const update of updates) {
yield withEventMeta(update, { id: update.id })
}
})import { RetryLinkPlugin } from '@orpc/client/plugins'
const link = new RPCLink({
url: '/rpc',
plugins: [new RetryLinkPlugin()],
})
const updates = await client.liveUpdates(undefined, {
context: { retry: Number.POSITIVE_INFINITY },
})
for await (const update of updates) {
render(update) // each event typed like any other output
}
// on disconnect, the plugin reconnects with lastEventId,
// and the handler resumes the stream where it left offYour router becomes your query layer. Keys, options, and invalidation, all derived.
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { client } from './client'
export const orpc = createTanstackQueryUtils(client)import { useQuery, useQueryClient } from '@tanstack/react-query'
import { orpc } from './orpc'
export function Planet({ id }: { id: number }) {
const queryClient = useQueryClient()
const { data } = useQuery(
orpc.planet.find.queryOptions({ input: { id } }),
)
const refresh = () => queryClient.invalidateQueries({
queryKey: orpc.planet.key(),
})
// keys derived from the router, typed input included
return <h1>{data?.name}</h1>
}The same procedure, now a Next.js Server Function. Same input, same typed errors.
'use server'
import { createServerFunction } from '@orpc/next'
import { findPlanet } from './router'
export const findPlanetAction = createServerFunction(
findPlanet,
{ context: async () => ({ user: await auth() }) },
)'use client'
import { findPlanetAction } from './actions'
export async function find(id: number) {
const [error, planet] = await findPlanetAction({ id })
if (error?.inferable && error.code === 'NOT_FOUND') {
// a declared failure, serialized and typed, no try/catch
}
else if (error) {
// anything unexpected, still kept out of the success path
}
return planet
}Call your API without running a server. Built for SSR and tests.
import { createRouterClient } from '@orpc/server'
import { router } from './router'
const serverClient = createRouterClient(router, {
context: { user: { id: 1 } },
})
export default async function Page() {
const planet = await serverClient.planet.find({ id: 1 })
// direct invocation, zero network hops
return <h1>{planet.name}</h1>
}import { call } from '@orpc/server'
import { findPlanet } from './router'
it('finds a planet', async () => {
const planet = await call(findPlanet, { id: 1 }, {
context: { user: { id: 1 } },
})
expect(planet.name).toBe('Earth')
})
// input validation and middleware still run, no server startedFeatures
Everything you need to build an API.
End-to-end types
Inputs and outputs keep their types from server to client, with no code generation.
Any schema library
Validate with Zod, Valibot, ArkType, or any library that follows Standard Schema.
Typed errors
Declare what a procedure can fail with, and the client handles each case by name.
Real JavaScript types
Date, BigInt, Set, and Map arrive as the type you sent, not as strings.
Files and binary data
Send a File up and stream bytes back, typed in both directions.
Live event streams
An async generator becomes a typed event stream, and dropped clients resume from the last event.
REST and OpenAPI
The same router answers REST calls and writes its own OpenAPI document.
Contract first
Write the contract first, then let TypeScript check that the server matches it.
Middleware and plugins
Extend any layer with middleware, plugins, and interceptors, all fully typed.
Works with your stack
TanStack Query, SWR, Pinia Colada, NestJS, and Next.js Server Actions work out of the box.
Tracing and logging
Add OpenTelemetry tracing and Pino or Evlog logging without changing your procedures.
Runs anywhere
One router runs on Node, Bun, Deno, Cloudflare Workers, and AWS Lambda.
Comparison
Measured against the alternatives.
oRPC, tRPC, and Hono all deliver typesafe APIs. Here is where they part ways, with the full feature matrix and reproducible benchmarks on the comparison page.
| Feature | oRPC | tRPC | Hono |
|---|---|---|---|
| End-to-end typesafe errors | first-class support | partial or third-party support | first-class support |
| End-to-end typesafe File and Blob | first-class support | partial or third-party support | partial or third-party support |
| End-to-end typesafe streaming | first-class support | partial or third-party support | not supported |
| Contract-first approach | first-class support | not supported | first-class support |
| OpenAPI spec generation | first-class support | partial or third-party support | first-class support |
| TanStack Query for every framework | first-class support | partial or third-party support | not supported |
- first-class support
- partial or third-party support
- not supported
4.3x
the RPC throughput of tRPC over HTTP, and 4.8x over WebSocket.
20%
faster type-checking than tRPC across 3,000 procedures, on 28% less memory.
14.1 kB
gzipped for a client and server pair, against 25.5 kB for tRPC.
All three are fast enough for production. The comparison page has the versions, the method, and every number these are drawn from.
Sponsors
Paid for by the people who build on it.
oRPC is MIT licensed and developed in the open. Sponsorship is what buys the time that goes into it.
With thanks to 38 past sponsors who helped get oRPC here.
Start with one procedure.
Add a schema when you want validation, a route when you want REST, a contract when the team grows. Your procedures and your client never change. The first one takes five lines.