GeezerVison is showing orpc.dev β oRPC - Typesafe APIs Made Simple πͺ v2 betaFollow the releases on GitHubTypesafe APIs, made simple.Write your API once. oRPC turns it into a fully typed client, a REST API with an OpenAPI document, MCP tools for AI agents, and typed realtime streams. No code generation, no lock-in to a runtime.
What oRPC gives youDefine a procedure on the server, call it like a local function. No codegen, no drift. router.ts 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
},
}
client.ts 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 generated
Failures are part of the API. Declare them once, and the client knows every case. router.ts 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
})
client.ts import { isDefinedError, safe } from '@orpc/client'
const [error, planet] = await safe(client.planet.find({ id: 1 }))
if (isDefinedError(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. contract.ts 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() })),
},
}
server.ts 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. middleware.ts 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
})
})
router.ts 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. router.ts 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' } },
})
request.http 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 document
Return a Date, get a Date. Send a File, get a File. The wire format is oRPCβs problem. router.ts 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)),
}
client.ts 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 base64
Stream events with a plain yield. Add the Retry plugin and clients resume from the last event. router.ts 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 })
}
})
client.ts 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 off
Your router becomes your query layer. Keys, options, and invalidation, all derived. orpc.ts import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { client } from './client'
export const orpc = createTanstackQueryUtils(client)
planet.tsx 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. actions.ts 'use server'
import { createServerFunction } from '@orpc/next'
import { findPlanet } from './router'
export const findPlanetAction = createServerFunction(
findPlanet,
{ context: async () => ({ user: await auth() }) },
)
find.tsx 'use client'
import { findPlanetAction } from './actions'
export async function find(id: number) {
const [error, planet] = await findPlanetAction({ id })
if (error?.defined && 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. page.tsx 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>
}
find.test.ts 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 started
Features Everything you need to build an API.End-to-end typesInputs and outputs keep their types from server to client, with no code generation. Any schema libraryValidate with Zod, Valibot, ArkType, or any library that follows Standard Schema. Typed errorsDeclare what a procedure can fail with, and the client handles each case by name. Real JavaScript typesDate, BigInt, Set, and Map arrive as the type you sent, not as strings. Files and binary dataSend a File up and stream bytes back, typed in both directions. Live event streamsAn async generator becomes a typed event stream, and dropped clients resume from the last event. REST and OpenAPIThe same router answers REST calls and writes its own OpenAPI document. AI agents with MCPThe same router serves AI agents as MCP tools, described by the metadata you already wrote. Contract firstWrite the contract first, then let TypeScript check that the server matches it. Middleware and pluginsExtend any layer with middleware, plugins, and interceptors, all fully typed. Tracing and loggingTrace with OpenTelemetry or Cloudflare Workers and log with Pino or Evlog, without touching your procedures. Runs anywhereOne router runs on Node, Bun, Deno, Cloudflare Workers, and AWS Lambda. Built in What a procedure needs in production.None of these are required. Add the ones you need, each built for oRPC rather than bolted on. All packagesComparison 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. Full comparison- β
first-class support
- π‘partial or third-party support
- πnot supported
4.2x the RPC throughput of tRPC over HTTP, and 4.4x over WebSocket. 30% faster type-checking than tRPC across 3,000 procedures, on 31% less memory. 14.5 kB gzipped for a client and server pair, against 23.7 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. All sponsorsWith thanks to 40 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. |