tRPC stands for TypeScript Remote Procedure Call. It’s a modern way to build APIs in Node.js apps that use TypeScript. Unlike REST or GraphQL, which usually needs a lot of setup and extra code, tRPC lets your front end talk to your backend directly without needing to write schemas or generate code.
With tRPC, you define your API functions (called "procedures") on the server, and the client automatically gets all the type information. This means you get autocomplete in your editor and TypeScript can catch errors before running the app. You won’t have to deal with mismatched API responses or runtime type bugs.
In this article, you’ll learn how to build a fully type-safe API for your Node.js app using tRPC.
Prerequisites
Before you get started, make sure you have the following:
- A recent version of Node.js installed (version 18.0.0 or higher is recommended)
- A basic understanding of TypeScript (things like types, interfaces, and generics)
- Some knowledge of web development basics (like APIs and how the client and server talk to each other)
Why use tRPC?
Traditional ways of building APIs, like REST and GraphQL, come with some built-in challenges.
With REST, you have to manually set up routes, handle different HTTP methods, and keep separate documentation to explain how the API works.
GraphQL solves some of those issues but also brings extra complexity, like writing schemas, creating resolvers, and learning a special query language.
tRPC takes a different approach by:
- Skipping schema files — you don’t have to write or maintain separate schema or type files
- Avoiding code generation — no need to run extra tools to keep your client and server in sync
- Using automatic type inference — TypeScript figures out the shape of your API for you
- Making validation easier — works smoothly with libraries like Zod or Yup
- Sending only what’s needed — keeps network traffic light by only sending necessary data
You still get all the essential features you’d expect in a modern API setup, like middleware, error handling, and caching. But tRPC is built with developer experience in mind and works especially well in JavaScript and TypeScript projects.
Step 1 — Setting up a basic Express API server
In this section, you'll create the directory structure and set up a conventional Express API server. This provides a foundation to demonstrate later how tRPC transforms API development.
First, create a new directory for your project and navigate into it:
Initialize a new Node.js project with npm:
Now, install the necessary dependencies for a basic Express API:
Let's break down these packages:
express– A minimal and flexible Node.js web frameworkcors– Middleware that enables Cross-Origin Resource Sharing for the APIzod– A TypeScript-first library for validating and parsing input datatypescript– The TypeScript compiler used to write and compile your codetsx– A fast TypeScript runtime that runs.tsfiles directly and automatically restarts on file changes during development@typespackages – Type definitions for Node.js, Express, and CORS, which enable TypeScript support for these libraries
Initialize TypeScript configuration:
This creates a default tsconfig.json file. Let's update it to support ECMAScript Modules (ESM) and proper output configuration:
This configuration tells TypeScript to target modern JavaScript (ES2023), use Node.js-style module resolution, and enable strict type checking to improve type safety. It also sets the output directory for compiled files to dist and generates source maps to help with debugging.
Update your package.json file to include the necessary scripts and set the project to use ESM:
In the highlighted code, you set the project to use ECMAScript Modules with "type": "module" and add scripts for development, building, and running the app. The dev script uses ts-node-dev for live reloading, build compiles the TypeScript code, and start runs the compiled app from the dist folder.
Create the basic directory structure for your project:
Now, let's create a simple Express API with a single endpoint for user creation. This endpoint will demonstrate input validation and error handling principles that we'll later enhance with tRPC.
Create an index.ts file in the src directory:
This index.ts file sets up a basic Express server with a simple POST endpoint to create new users, using Zod for input validation. Here's a quick breakdown:
- It imports the required libraries: Express for the server, CORS for cross-origin requests, and Zod for validating request data.
- A mock in-memory database holds user data.
- A Zod schema (
userSchema) defines rules for valid user input: names must be at least 2 characters, and emails must be in a valid format. - A custom middleware function
validateRequestuses this schema to validate incoming request bodies before they reach the route handler. - The
/api/usersroute handles POST requests to add new users, checks for duplicate emails, and returns the new user if successful. - The app listens on port 3000 and logs the server URL on startup.
Run your server using:
You should see output similar to:
You can test the API using curl, or with a tool like Postman if you prefer a visual interface:
If the request is successful, you should get a response like this:
If you're using Postman, set the method to POST, the URL to http://localhost:3000/api/users, and include the same JSON body.
Let's also try with invalid inputs to see the validation in action:
The response shows Zod's structured validation errors:
If you're using Postman, you can test the same invalid input by keeping the method as POST, and using the same endpoint and body. Here's what the validation error looks like in Postman:
Now you have a working Express API with proper validation, but there are still a few limitations:
- You need to manually define TypeScript interfaces to reuse types outside of Zod validation.
- Your client doesn't automatically know the API's input and output types, so you have to maintain separate documentation or types.
- You still need to set up routes, HTTP methods, and handle requests and responses by hand.
- There's no built-in way to generate API documentation.
In the next step, you'll see how tRPC can help solve these issues with end-to-end type safety and a smoother development experience.
Step 2 — Transforming to a tRPC API
Now that you have a conventional Express API, let's transform it using tRPC to demonstrate how it addresses the limitations of traditional REST APIs. We'll implement the same user creation functionality but with tRPC's end-to-end type safety.
Let's examine how tRPC fundamentally changes API development. This diagram shows the streamlined communication between client and server components.
Unlike REST APIs with multiple endpoints, tRPC uses a single channel while automatically sharing types between server and client without code generation or schema duplication.
The client makes fully type-safe procedure calls through one HTTP endpoint, with complete IDE support. The server focuses on business logic rather than HTTP concerns, while type information flows automatically between them.
First, install the necessary tRPC dependencies:
Let's break down these new packages:
@trpc/server– The core server-side library for creating tRPC procedures and routers@trpc/client– The client library for consuming tRPC APIs with full type safety
Now, update your src/index.ts file to use tRPC instead of the Express route:
Compared to the earlier Express version, this tRPC setup is much cleaner and more focused on developer experience.
Instead of defining individual routes and HTTP methods, you define procedures like createUser that describe what your app can do. Validation is built into the procedure using Zod, so you don’t need a separate middleware for it — just pass the schema to .input() and tRPC handles the rest.
Error handling also gets easier. Rather than manually setting status codes and crafting responses, you can throw a TRPCError and tRPC will handle it consistently.
On top of that, type safety is fully automatic. By exporting AppRouter, clients can import the types of all your procedures without writing a single extra interface or type. And instead of having a bunch of different route URLs, all your procedures are available through one endpoint: /trpc.
With tRPC, the server implementation is more concise and contains fewer manual steps. Validation, error handling, and HTTP concerns are abstracted away, allowing you to focus on your business logic.
Now, let's create a simple client to test our tRPC API. Create a new file called client.ts in the src directory:
What’s great about this client code is how effortless it is to work with. The call to client.createUser.mutate() is fully type-safe. TypeScript knows exactly what input the procedure expects, what it returns, and even gives you autocomplete as you type.
You don’t need to define or sync any types between server and client — it all works because both sides share the same AppRouter type.
Even error handling feels smooth. If the server throws an error or the input fails validation, the client receives that information directly, including detailed Zod errors. No need to wire up custom error formats or status codes.
Restart the server again:
Now open a new terminal and run the client like this:
You should see output similar to:
What stands out most here is the type safety. The client doesn’t just “guess” what the server expects — it knows. That’s the real power of tRPC: no extra type definitions, no code generation, just shared types and a smooth dev experience.
Step 3 — Implementing a modular tRPC structure
Now let's reorganize our tRPC implementation by separating concerns and creating a more maintainable structure. This approach follows best practices for larger applications by placing tRPC-related code in dedicated files.
First, let's create a directory structure for our tRPC code:
Now, let's create a dedicated router file that will contain our tRPC procedure definitions:
Now, let's update our main server file to use the modular router:
In the highlighted code, you make a few key updates to support a modular tRPC setup within your Express server:
- Import
createExpressMiddlewarefrom@trpc/server/adapters/expressand theappRouterfrom your modular router file. This sets the foundation for tRPC integration. - Add a basic test route (
/) that returns a simple message. This helps confirm the server is running and makes it easier to verify Express is working before testing the tRPC endpoint. - Use
express.json()middleware to enable JSON parsing for incoming requests, which is required for tRPC to process request bodies. - Attach the tRPC middleware to the
/trpcpath and pass in yourappRouter. You also define a basiccreateContextfunction and use theonErrorhook to log any errors that occur in your tRPC procedures. - Finally, when starting the server, you print both the root server URL and the tRPC endpoint to the console so it's clear where the API is accessible.
Since you've moved the AppRouter type to a new location, you need to update your client file:
This modular setup makes your project easier to work with in several ways.
First, keeping your router logic separate from the main server file helps keep things clean and focused — each file does one job, which makes the code easier to read and update.
As your API grows, you can split procedures into different files and combine them into a single router without cluttering the server setup.
It also makes testing and making changes less of a headache since your business logic isn’t buried inside the HTTP layer. Overall, this structure just makes the project more maintainable and approachable, especially when other developers are jumping in.
Try running the server and client with this new structure:
And in another terminal:
This modular approach is a best practice for larger tRPC applications, making the codebase more maintainable as it grows.
Step 4 — Expanding Your tRPC API with Query Procedures
Now that you have a modular tRPC structure in place, let's see how easy it is to expand our API by adding query procedures. In REST, this would require setting up new routes, but with tRPC, it's as simple as adding new procedures to your router.
Let's update our router file to include two new procedures:
- Getting a list of all users
- Getting a single user by ID
In this code, you add two new read-only procedures to your tRPC router to make your API more useful.
First, you define a simple userIdSchema using Zod to validate that the input for getUserById contains a string id. This ensures that when someone tries to fetch a specific user, the input is type-safe and properly structured.
Then, you add two new procedures to the router:
getUsersis a query that returns the full list of users. It doesn’t take any input and just returns theusersarray. This is useful for listing all users in the system.getUserByIdaccepts anidas input, looks for a user with that ID, and returns the user if found. If no match is found, it throws aTRPCErrorwith aNOT_FOUNDcode and a helpful error message. This way, error handling stays consistent and clear on the client side.
Together, these additions make your API more flexible and realistic, showing how easy it is to scale a tRPC router with more procedures.
Now let's update our client code to test these new procedures:
In this updated client, you’re testing the full flow of your tRPC API with the new procedures. First, it fetches and logs all users using getUsers, which confirms that the server is returning data correctly.
Then, it retrieves a specific user by ID using getUserById, verifying that input validation and lookup are working as expected. After that, it creates a new user with createUser, showing that the mutation runs successfully and returns the newly added user.
The entire interaction is fully type-safe from end to end. TypeScript ensures you pass the correct input to each procedure and gives you autocomplete and error checking as you write. Any errors that occur—whether from validation, duplicate data, or missing users—can be caught and handled just like normal exceptions, making your development experience smoother and more predictable.
Run your server and client to test these operations:
And in another terminal:
You should see output showing each operation being performed successfully:
What’s nice about this is how smooth it is to add and use new functionality with tRPC. Compared to a traditional REST API, there’s a lot less overhead and boilerplate. Here’s why:
- No route configuration — you don’t have to define paths, HTTP methods, or manually wire up parameter parsing. Just add a new procedure.
- Type safety is built in — Zod handles input validation, and TypeScript enforces the correct types on both the client and server.
- Error handling is consistent — when a user isn’t found, you throw a
TRPCError, and tRPC takes care of the response formatting and status code. - The client updates automatically — once a procedure is added to the router, the client can call it with full type support and autocomplete, without writing extra code or generating types.
This is what makes tRPC such a powerful tool for TypeScript projects. You focus on writing your logic; everything else—validation, typing, error handling, and client integration—comes along automatically.
Final thoughts
tRPC makes building APIs in TypeScript fast, type-safe, and boilerplate-free. Instead of juggling route configs and manual types, you focus on your logic—and the types just work, end to end.
What you’ve built here is a solid foundation. To go further, consider adding update/delete operations, authentication, or connecting to a real database.
Check out the tRPC documentation for more features and best practices.