Sitemap

Getting Started with tRPC: End-to-End Type Safety

5 min readApr 30, 2025

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 packages
  • zod: For runtime validation and type inference
  • typescript 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. Here createUser 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 the profile procedure, we used isAuthenticated 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 our AppRouter 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 withhttpBatchLink, 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

Connect with me

--

--

No responses yet