Getting Started with tRPC: End-to-End Type Safety
If you’re building a full-stack TypeScript app and are tired of duplicating types between the backend and frontend, tRPC might just become your favorite tool. It gives you end-to-end type safety without writing a single GraphQL schema or Swagger file.
In this post, I’ll walk you through the what, why, and how of tRPC. By the end, you’ll understand how a client can talk to a server with a typesafe API.
What is tRPC?
tRPC stands for TypeScript Remote Procedure Call. It allows you to build fully typesafe APIs without needing to define and sync schemas between client and server.
Instead of REST or GraphQL, where you often write schemas and clients, tRPC lets you call backend functions from the frontend as if they were local, all with full TypeScript support.
Why use tRPC?
- No code duplication between backend and frontend
- End-to-end type safety
- No need for a separate client or schema
- Amazing developer experience with auto-completion and inline documentation
Getting Started
Let’s build a simple application with a user management API. We’ll create a basic tRPC server and client setup.
Prerequisites
Make sure you have Node.js and npm installed on your system.
Step 1: Project Setup
First, let’s create a new directory for our project and initialize it:
mkdir trpc-demo
cd trpc-demo
mkdir server
mkdir client
npm init -y
tsc --init
Let’s update our tsconfig.json
:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Install the required dependencies:
npm install @trpc/server @trpc/client zod
npm install -D typescript @types/node
@trpc/server
and@trpc/client
: The core tRPC packageszod
: For runtime validation and type inferencetypescript
and@types/node
: For TypeScript support
Step 2: Setting up the Server
Create server/trpc.ts
file and initialize tRPC with the application context.
import {initTRPC} from '@trpc/server';
import {AppContext} from "./types";
const t = initTRPC.context<AppContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
Create server/app.ts
import {router} from './trpc';
export const appRouter = router({
// your routers
});
Create server/types.ts
import {appRouter} from "./app";
export type AppRouter = typeof appRouter;
export type AppContext = {
userId?: string;
}
AppRouter
: Type that represents the full tRPC API (gives your frontend complete type awareness of all your backend routes.)AppContext
: Per-request context object passed to all procedures (used for auth, DB access, etc.)
Now let's create server/server.ts
import {createHTTPServer} from '@trpc/server/adapters/standalone';
import {appRouter} from "./app";
const server = createHTTPServer({
router: appRouter,
createContext(opts) {
// This is where you can create a context for your procedures, this will execute before reaching to the procedure
let authHeader = opts.req.headers['authorization'];
console.log('Auth header:', authHeader);
// You can use the auth header to verify and get the user ID or any other information
return {
userId: "12345", // The return type is same as the AppContext.
};
}
});
server.listen(5000); // run the sever on port 5000
Now let's start creating some basic routes and middleware
Create a server/middleware.ts
import {TRPCError} from "@trpc/server";
import {middleware} from "./trpc";
export const isAuthenticated = middleware(async (opts) => {
const {ctx} = opts; // fetching AppContext
const userId = ctx.userId;
if (!userId) {
throw new TRPCError({code: "UNAUTHORIZED"})
}
// Other validations can be done here
return opts.next()
})
Create a server/routers/user-router.ts
import {publicProcedure, router} from '../trpc';
import {z} from 'zod';
import {isAuthenticated} from "../middleware";
export const userRouter = router({
createUser: publicProcedure
.input(z.object({name: z.string(), email: z.string().email()}))
.output(z.object({name: z.string(), email: z.string().email()}))
.mutation(async (opts) => {
const {name, email} = opts.input; // retrive the input
const user = {name, email};
// Here you would typically save the user to a database
console.log('Creating user:', user);
return user;
}),
profile: publicProcedure
.use(isAuthenticated) // middleware to check if user is authenticated
.output(z.object({name: z.string(), email: z.string().email()}))
.query(async (opts) => {
const currUserId = opts.ctx.userId; // retrive userId from AppContext
// Fetch user profile from database or any other source
return {
name: 'Nayan Prasad P K',
email: 'nayanprasad096@gmail.com'
}
}),
});
Here we are creating two procedures createUser
(for user creation )and profile
(for getting current user data).
Let's break down publicProcedure
functional calls
input()
: Defines and validates the expected input data structure for the procedure using Zod schemas. HerecreateUser
we are expecting a name and email from the client.output()
: Defines the response data structure using Zod schemas.use()
: This is where we can pass middleware as arguments. In theprofile
procedure, we usedisAuthenticated
middleware. And this will run before the main procedure.query(async (opts) > {...})
: This is a procedure used for retrieving data.mutation(async (opts) > {...})
: This is a procedure used for creating, updating, or deleting data.
Now use this router in server/app.ts
import {router} from './trpc';
import {userRouter} from "./routers/user-router";
export const appRouter = router({
user: userRouter
});
Step 2: Setting up the Client
Create client/index.ts
file
Let’s initialize a tRPC client so it can communicate with our backend server and access our server procedures with full TypeScript support.
import {createTRPCClient, httpBatchLink} from '@trpc/client';
import type {AppRouter} from '../server/types';
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:5000', // server url
async headers() {
return {
Authorization: "Bearer 12345", // This is just a placeholder. You can replace it with the actual token (from localStorage).
}
}
}),
],
});
createTRPCClient<AppRouter>()
: We're creating a type-safe client using ourAppRouter
type (exported from the server). This makes our client aware of what procedures exist, their input types, and what response types to expect.httpBatchLink
: This is a transport mechanism that batches multiple requests into a single HTTP call, improving performance. It also allows us to set custom headers (like auth tokens).
Now, let's call the procedures
import {createTRPCClient, httpBatchLink} from '@trpc/client';
import type {AppRouter} from '../server/types';
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:5000',
async headers() {
return {
Authorization: "Bearer 12345", // This is just a placeholder. You can replace it with the actual token (from localStorage).
}
}
}),
],
});
const createUser = async () => {
const createUserResponse = await trpc.user.createUser.mutate({name: "Hades", email: "hades@gmail.com"})
console.log(createUserResponse);
}
const getProfile = async () => {
const profileResponse = await trpc.user.profile.query();
console.log('Profile:', profileResponse);
}
createUser();
Step 3: Building and Running the Project
Let’s update our package.json
With some useful scripts:
{
"scripts": {
"build": "tsc -b",
"start:server": "node dist/server/server.js",
"start:client": "node dist/client/index.js"
}
}
Build our TypeScript project: npm run build
Start the server: npm run start:server
Run the client: npm run start:client
Conclusion
We’ve now built a simple, fully type-safe, end-to-end application using tRPC — without writing REST routes or syncing GraphQL schemas. From creating procedures to sharing types across the server and client, tRPC simplifies the full-stack developer experience while ensuring complete type safety.
But this is just the beginning.
Advanced Topics to Explore
Once you’re comfortable with the basics, here are some powerful features worth exploring:
- Error Handling: Use standardized
TRPCError
to return meaningful error responses. - Batching: Improve performance with
httpBatchLink
, which bundles multiple procedure calls into a single HTTP request. - Subscriptions: Use WebSockets with tRPC to build real-time features like chats, notifications, or live dashboards.
- And more….
Resources
- Official tRPC Documentation: https://trpc.io/docs/getting-started
- GitHub Repository: https://github.com/nayanprasad/tRPC-demo