GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • oRPC
  • Documentation
  • Blog
  • Comparison
  • From V1
  • Sponsors
  • Experts
  • Releases
  • Discussions
  • LLM Context
  • LLM Context (Full)
  • V1 Documentation

This view

  • Original page
  • New address

GeezerVison is showing orpc.dev β€” oRPC - Typesafe APIs Made Simple πŸͺ„

v2 betaFollow the releases on GitHub

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

Get startedPlaygroundsGitHub5.8K

What oRPC gives you

Define 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
  • 10.2MDownloads each month
  • 99.9%Test coverage
  • 5.8KGitHub stars
  • MITLicensed, free forever

Runs on

  • Node.js
  • Bun
  • Deno
  • Cloudflare Workers
  • AWS Lambda
  • Fastify
  • Fetch API
  • WebSocket
  • MessagePort
  • Browser
  • Expo

Works with

  • TanStack Query
  • SWR
  • Pinia Colada
  • Next.js
  • NestJS
  • Effect
  • AI SDK
  • tRPC
  • Standard Schema
  • OpenTelemetry
  • Pino

Features

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.

AI agents with MCP

The same router serves AI agents as MCP tools, described by the metadata you already wrote.

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.

Tracing and logging

Trace with OpenTelemetry or Cloudflare Workers and log with Pino or Evlog, without touching your procedures.

Runs anywhere

One 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 packages
  • File uploads

    @orpc/node

    Stream large uploads to disk instead of buffering them in memory.

    Backed by

    • Node
  • Publish and subscribe

    @orpc/publisher

    Publish an event once and every live subscriber receives it.

    Backed by

    • In-memory
    • Redis
    • Upstash
    • Bun
    • Cloudflare
  • Rate limiting

    @orpc/ratelimit

    Cap what each key may consume, and tell clients when to retry.

    Backed by

    • In-memory
    • Redis
    • Upstash
    • Bun
    • Cloudflare
  • Locking

    @orpc/experimental-lock

    Run one call per key at a time, and tell handlers when they had to wait.

    Backed by

    • In-memory
    • Redis
    • Upstash
    • Bun
    • Cloudflare
  • Smart coercion

    @orpc/json-schema

    Coerce request and response values into the types your schema declares.

    Backed by

    • Standard Schema
  • Cookies and crypto

    @orpc/server/helpers

    Read and write cookies, and sign or encrypt any value.

  • API reference UI

    @orpc/openapi/plugins

    Serve interactive Scalar or Swagger docs straight from your router.

  • Request batching

    @orpc/server/plugins

    Combine many calls into one request and get their responses together.

  • Caching, not released yet

    Soon

    A caching layer for procedures.

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.

Full comparison
FeatureoRPCtRPCHono
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🟑partial or third-party support
OpenAPI spec generationβœ…first-class support🟑partial or third-party support🟑partial or third-party 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.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 sponsors
  • ScreenshotOne.comPremium Sponsor
  • YuzuPremium Sponsor
  • MisskeyHQPremium Sponsor
  • LN MarketsOrganization

Sponsors and backers

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

Get startedPlaygroundsGitHub5.8K
You are visitor 01402503 β€” GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800Γ—600. No cookies required.