How to Build Type-Safe APIs with tRPC in Node.js (2026 Guide)
As of March 2026, TypeScript has become the undisputed standard for modern API development. With the upcoming TypeScript 7.0 featuring the Go-based compiler (Project Corsa) for 10x faster builds, developers are increasingly demanding end-to-end type safety across their entire stack. This is where tRPC comes in.
tRPC (TypeScript RPC) is revolutionizing how we build APIs by eliminating the traditional separation between backend and frontend type definitions. Instead of manually writing OpenAPI specs or GraphQL SDL, tRPC lets you share TypeScript types directly between your server and client—giving you compile-time safety across your entire API surface.
In this guide, you will learn how to build production-ready APIs with tRPC in Node.js.
Why tRPC in 2026?
The traditional API development workflow involves writing your backend, then creating a separate schema (OpenAPI, GraphQL), then generating types for your frontend. This creates a painful synchronization problem:
- Backend changes require manual updates to the schema
- Type definitions can drift out of sync
- Refactoring becomes risky and time-consuming
tRPC solves this by letting you call backend functions directly from the frontend with full type inference. Change your backend function signature, and your frontend code immediately shows type errors.
Setting Up Your First tRPC API
Create a new project and install the required dependencies:
mkdir my-trpc-api && cd my-trpc-api
npm init -y
npm install @trpc/server@next zod
Zod is used for runtime validation that also infers TypeScript types.
Creating the tRPC Router
Create a file called server.ts:
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
// Create your router
const appRouter = t.router({
// Query example - get user by ID
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// In production, this would be a database call
return {
id: input.id,
name: "John Doe",
email: "john@example.com",
createdAt: new Date().toISOString()
};
}),
// Mutation example - create a new user
createUser: t.procedure
.input(z.object({
name: z.string().min(2),
email: z.string().email()
}))
.mutation(({ input }) => {
// In production, save to database
return {
id: Math.random().toString(36).substr(2, 9),
...input,
createdAt: new Date().toISOString()
};
}),
// Query with error handling
getUsers: t.procedure
.query(({ ctx }) => {
// Return list of users
return [
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" }
];
})
});
// Export type definition of your API
export type AppRouter = typeof appRouter;
Adding Error Handling
Real-world APIs need robust error handling. tRPC makes this straightforward:
import { TRPCError } from "@trpc/server";
// Inside your procedure
const getUserById = t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const user = findUser(input.id);
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: `User with ID ${input.id} not found`
});
}
return user;
});
Exposing Your API with HTTP
To make your tRPC router accessible via HTTP, use the @trpc/server/adapters/fastify or Express adapter:
npm install @trpc/server@next @trpc/client@next express
Create index.ts:
import express from "express";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter } from "./server";
const app = express();
app.use("/trpc", createExpressMiddleware({
router: appRouter,
}));
app.listen(4000, () => {
console.log("Server running on http://localhost:4000/trpc");
});
Using tRPC on the Client
On the client side, you get full type inference without any code generation:
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server";
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:4000/trpc",
}),
],
});
// Type-safe API calls - fully typed!
const user = await client.getUser.query({ id: "123" });
const newUser = await client.createUser.mutation({
name: "Jane",
email: "jane@example.com"
});
The user and newUser variables are fully typed based on your backend implementation. If you change your backend return type, TypeScript immediately flags errors in your client code.
Best Practices for tRPC in Production
Always validate input with Zod: Zod schemas serve as both runtime validators and TypeScript type definitions.
Use middleware for cross-cutting concerns:
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { user: ctx.user } });
});
const protectedProcedure = t.procedure.use(isAuthed);
- Organize routers by domain: Split large APIs into multiple routers:
const userRouter = t.router({ /* user procedures */ });
const productRouter = t.router({ /* product procedures */ });
const appRouter = t.router({
users: userRouter,
products: productRouter,
});
- Add caching headers for GET queries to improve performance.
Conclusion
tRPC represents a fundamental shift in API development—moving from schema-first to code-first type sharing. As TypeScript adoption continues to grow in 2026, with tools like Project Corsa making builds 10x faster, tRPC is positioned to become the default choice for internal APIs in TypeScript applications.
The benefits are clear: zero schema synchronization overhead, end-to-end type safety, and a developer experience that makes API calls feel like local function calls. For teams already invested in TypeScript, tRPC eliminates the biggest pain point in traditional API development.
Give tRPC a try in your next project—you will wonder how you ever built APIs without it.
Top comments (0)