oRPC: The Ultimate tRPC Replacement for Modern Developers
For some time, tRPC has been a fantastic tool in my arsenal, but a new library has emerged that promises to be a compelling replacement, offering several more first-party features: oRPC. If you're familiar with tRPC, you'll find oRPC incredibly intuitive. If you're new to both, this article will introduce you to the powerful world of typesafe, end-to-end APIs.
To highlight the key differences from tRPC, we'll quickly walk through an oRPC setup. You'll see just how similar the initial configuration and usage are, and then we'll dive into what sets oRPC apart. This demonstration uses Next.js, but oRPC provides numerous integrations for almost any environment you might need.
Quick Setup: oRPC Explained in 5 Minutes
Let's begin by creating a simple procedure.
1. Create a Procedure
First, we import os
from oRPC/server
and use it to define our procedures. As shown below, we're creating a procedure with input validation using a Zod schema. It's worth noting that oRPC supports various standard schema libraries like Zod, ArkType, Valibot, and more.
The handler is where the business logic resides. Here, we can access validated arguments from the input and use them in our function. In this case, we're simply returning a "hello" message with the provided name.
import { os } from 'oRPC/server';
import { z } from 'zod';
const helloWorldProcedure = os.proc({
input: z.object({ name: z.string() }),
handler: ({ input }) => {
return `hello ${input.name}`;
}
});
2. Create the Router Next, we create a router, which is a plain JavaScript object that maps a key (the route name) to a procedure.
const router = {
hello: helloWorldProcedure,
};
3. Create the Server
This part varies based on your chosen integration. Since we're using Next.js, we just need to create an API route with the oRPC handler. The handler is instantiated with our router, and we use handleRequest
to manage GET, POST, PUT, PATCH, and DELETE methods. The setup is straightforward, involving passing the request and a prefix for our routes (in this case, /rpc
).
// app/api/rpc/[...route]/route.ts
import { createRPCHandler } from 'oRPC/server';
import { router } from './router'; // Assuming router is in the same directory
const handler = createRPCHandler({ router });
export const { GET, POST, PUT, PATCH, DELETE } = {
GET: (req) => handler.handleRequest(req, { prefix: '/api/rpc' }),
POST: (req) => handler.handleRequest(req, { prefix: '/api/rpc' }),
PUT: (req) => handler.handleRequest(req, { prefix: '/api/rpc' }),
PATCH: (req) => handler.handleRequest(req, { prefix: '/api/rpc' }),
DELETE: (req) => handler.handleRequest(req, { prefix: '/api/rpc' }),
};
4. Create the Client
The final step is creating the oRPC client to call our procedures. We set up an rpcLink
to establish communication between the client and server. This link points to our RPC server's URL.
Then, we create a typesafe oRPC client using the router
type we defined earlier.
import { createRPCClient, rpcLink } from 'oRPC/client';
import type { AppRouter } from './server'; // Import the router type
const client = createRPCClient<AppRouter>({
links: [
rpcLink({
url: 'http://localhost:3000/api/rpc',
}),
],
});
Now, wherever we want to use our procedures, we import the client. The developer experience is excellent; you get full autocompletion for all available routes. Calling the procedure is as simple as invoking a function and passing the validated input. The response type is automatically inferred from the handler function.
import { client } from './client';
const response = await client.hello.query({ name: 'World' });
// response is inferred as a string
This quick setup demonstrates how rapidly you can establish a typesafe, end-to-end API with oRPC, showcasing a developer experience similar to tRPC with its robust type safety and autocompletion.
Furthermore, oRPC supports patterns like the builder pattern for creating public and protected procedures with attached middleware for database connections or authentication, much like tRPC.
// Public procedure with database middleware
const publicProcedure = os.proc({
middleware: [dbMiddleware],
});
// Protected procedure with added authentication
const protectedProcedure = publicProcedure.use(authMiddleware);
// Usage
const userProcedure = protectedProcedure.handler(({ ctx }) => {
// ctx.user is available here
});
What Makes oRPC Better Than tRPC?
So, what are the standout features that might make you switch?
1. First-Party OpenAPI Support
The "O" in oRPC stands for OpenAPI, and this is a core reason the library was created. It makes generating OpenAPI-compliant APIs incredibly simple. The library can generate a spec directly from your procedures, but the integration with Scalar is even more impressive.
By adding the following code to a Next.js route, you get beautiful, interactive OpenAPI reference documentation.
import { createOpenApiHttpHandler } from 'oRPC/server';
import { router } from './router';
const handler = createOpenApiHttpHandler({ router });
export const { GET } = {
GET: (req) => handler(req)
};
This generates a fantastic interface at the /api
endpoint. You can see all your authentication and data endpoints, and clicking into them reveals required parameters like name
and email
. It shows implementation examples for various languages and even includes a built-in API client to test requests and see responses directly in the documentation. This system also supports multiple authentication types, such as passing a Bearer token for protected routes.
To enable this, you might need to make a small change to your procedures by using the .route()
function to define the method, path, summary, and tags for the OpenAPI spec. You may also want to define the output schema for automatic spec generation.
os.proc({
//...
route: {
method: 'GET',
path: '/planets',
summary: 'Get a list of planets',
tags: ['planets'],
},
output: z.array(planetSchema),
//...
});
While a tRPC plugin for this existed, it has been unmaintained. A newer plugin, trpc-to-openapi
, is available, but oRPC offers this powerful feature as a first-party, fully integrated solution.
2. Advanced Error Handling
While you can still throw standard JavaScript errors, oRPC provides a specialized oRPCError
class for more structured error handling.
Consider a procedure to find a planet by ID. If the planet doesn't exist, we want to throw a "Not Found" error.
import { oRPCError } from 'oRPC/server';
// ... inside a handler
if (!planet) {
throw new oRPCError({
code: 'NOT_FOUND',
message: 'Planet not found.',
});
}
The error codes are fully typed, offering all the standard options you'd expect, like BAD_REQUEST
, UNAUTHORIZED
, and INTERNAL_SERVER_ERROR
.
For a more advanced experience, you can define error types directly on the procedure using the .errors()
method. This allows you to provide a default message and validate the schema of the error data.
os.proc({
//...
errors: {
NOT_FOUND: {
message: 'Planet could not be located.',
schema: z.object({ id: z.number() })
}
},
handler: ({ input, errors }) => {
// ...
if (!planet) {
throw errors.NOT_FOUND({ data: { id: input.id } });
}
// ...
}
});
This approach provides autocomplete and type safety when throwing errors. On the client side, handling these errors is just as elegant. By wrapping the procedure call in the safe()
function from oRPC/client
, you get a tuple or object containing either the data or the error, similar to a try/catch
block but with inferred error types.
import { safe } from 'oRPC/client';
const { data, error } = await safe(client.planet.find.query({ id: 123 }));
if (error) {
if (error.code === 'NOT_FOUND') {
// error.data is typed as { id: number }
console.log(`Planet with ID ${error.data.id} not found.`);
}
}
This system allows for incredibly robust and typesafe error handling from the server all the way to the client.
3. Contract-First Approach
Another powerful feature unique to oRPC is the ability to use a contract-first design pattern. This is similar to test-driven development, where you define your API contract before writing the implementation. A contract specifies the rules for a procedure, including input, output, and error types.
In practice, you use oc
from oRPC/contract
to define your contracts without any implementation details.
import { oc } from 'oRPC/contract';
const planetContract = {
list: oc.proc({
input: z.void(),
output: z.array(planetSchema),
}),
// ... other contracts
};
Once the contract is defined, you implement it using the implement()
function from oRPC/server
. The handler's input and output are now validated against the contract, and any mismatch will result in a type error.
import { os } from 'oRPC/server';
const planetProcedures = os.implement(planetContract).list({
handler: () => {
// ... implementation
// The return value must match the contract's output schema
return allPlanets;
}
});
This feature is a game-changer if you prefer to design your APIs before building them.
Conclusion
These are just a few of the features that make oRPC an exceptional and compelling choice. There are many more, including built-in plugins for CORS, batching, retrying, and rate limiting. Additionally, there is first-party TanStack Query support not only for React but also for Vue, Svelte, and Solid. If you value first-party OpenAPI support, advanced error handling, and the option for a contract-first approach, switching to oRPC is an absolute must.
Join the 10xdev Community
Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.