For a long time, my default for internal TypeScript APIs was tRPC. The pitch is compelling: define your procedures on the server, get fully inferred types on the client, skip writing any schema by hand. If you're building a full-stack Next.js application and both sides are TypeScript, it's a genuinely good choice.
The problem I kept running into was at the seams — when the API needed to be consumed by something that wasn't TypeScript, or when the HTTP contract needed to be explicit enough for a third party to integrate against, or when I wanted to use a different HTTP framework on the backend. tRPC's tight coupling to its own HTTP adapter made those cases painful.
oRPC is a newer library that starts from the same insight — end-to-end type safety without writing schemas twice — but makes different tradeoffs. Here's what changed my mind.
What tRPC gets right
I don't want to be unfair to tRPC. The core idea is correct: in a TypeScript monorepo, having the server's return types automatically available in the client eliminates an entire class of bugs. You refactor a procedure's return shape, TypeScript errors appear in every callsite, you fix them, you're done. No regenerating a schema, no updating a type file, no drift.
For Pulse, the admin UI and the API server are both TypeScript and both in the same Turborepo workspace. The coupling is fine there. It's exactly the use case tRPC was designed for.
Where tRPC creates friction
The issues surface as soon as you step outside the happy path.
OpenAPI generation is an afterthought. tRPC's HTTP transport uses a custom format that's not standard REST. Third-party OpenAPI adapters exist but they're maintained separately and lag behind tRPC versions. When a product needs documented, versioned HTTP endpoints for external consumers — which every product eventually does — this becomes a real cost.
Framework lock-in. tRPC has adapters for Express, Fastify, and Next.js, but the HTTP semantics are still tRPC's. You're not building an HTTP API; you're building a tRPC API that happens to run over HTTP. The difference matters when you want to add middleware that operates on standard HTTP request shapes.
Non-TypeScript consumers. If a mobile app, a Python script, or a third-party integration needs to call your API, they're either using a generated client or making raw HTTP calls and hoping the types are documented somewhere. tRPC's strength is also its weakness: the type sharing only works when both sides are TypeScript.
What oRPC does differently
oRPC's approach is to treat the procedure definition as a schema that can be projected into multiple outputs: a TypeScript client, an OpenAPI spec, a Fastify plugin. The same definition generates all of them.
import { oc } from "@orpc/server";
import { z } from "zod";
export const createOrder = oc
.input(z.object({
restaurantId: z.string().uuid(),
items: z.array(z.object({
menuItemId: z.string(),
quantity: z.number().int().min(1),
})),
paymentMethodId: z.string(),
}))
.output(z.object({
orderId: z.string(),
status: z.enum(["pending", "confirmed"]),
total: z.number(),
}))
.func(async ({ input }) => {
// handler logic
return orderService.create(input);
});From this single definition, oRPC gives you: a TypeScript client with fully inferred input and output types, an OpenAPI 3.1 specification you can serve from /docs, and a Fastify route handler you can mount however you want.
The thing that actually made me switch
The feature that pushed me from evaluation to adoption was first-class OpenAPI output. Not an adapter, not a plugin, not a community package — a first-class output format that's part of the core library.
At Websmiths, the products I build need documented HTTP APIs that non-TypeScript consumers can integrate against. With tRPC I was maintaining the tRPC procedures and a separate OpenAPI spec written by hand, hoping they stayed in sync. With oRPC, the spec is generated. If the procedure changes, the spec changes. It's the same insight as Define — one source of truth, multiple targets.
import { OpenAPIGenerator } from "@orpc/openapi";
import { router } from "./router";
const generator = new OpenAPIGenerator({ router });
// Mount on /docs — always in sync with the actual procedures
app.get("/openapi.json", async () => generator.generate());Where I still use tRPC
I'm not suggesting you migrate your tRPC codebase. If you're in a full-stack TypeScript monorepo where you control both the server and the client and you never need to document the API for external consumers, tRPC is a perfectly good choice. The DX is excellent and the ecosystem is mature.
My rule now is: tRPC for internal tools where the client is always TypeScript and always mine; oRPC for anything that needs to be an HTTP API first and a TypeScript API second. The distinction is about who your consumers are and whether you'll ever need to hand someone a URL and say "here's how to call this."
The bigger pattern
oRPC is part of a broader trend I find encouraging: schema-first design that generates code in multiple directions rather than code that tries to infer a schema. Define does this for databases and validators. oRPC does it for APIs. The common thread is that the schema is the single point of truth, and everything else — types, documentation, handlers, clients — is derived from it.
That's a more durable architecture than one where types are inferred from implementation. When the schema is explicit, you can project it into any target. When the schema is implicit — inferred from the code that happens to exist today — you're tied to that code's shape forever.