Introduction to tRPC: Strongly Typed APIs Without Schemas
Building full-stack TypeScript applications traditionally involves maintaining separate type definitions on the client and server. You write your backend endpoints, manually create matching types on the frontend, and hope nothing drifts out of sync. tRPC eliminates this problem entirely by allowing you to share types directly between your server and client code — no code generation, no schema files, no manual synchronization.
What Is tRPC?
tRPC is a TypeScript RPC (Remote Procedure Call) framework that lets you build end-to-end type-safe APIs. Instead of defining a schema in a separate language like GraphQL SDL or OpenAPI, you write plain TypeScript functions on the server and call them directly from the client with full type inference. The compiler guarantees that the arguments you pass and the data you receive match what the server expects.
Why It Matters
- Zero code generation: Types flow automatically from server to client through TypeScript's inference engine.
- Catch errors at compile time: Rename a field on the server and the client immediately shows a type error.
- Excellent developer experience: Autocomplete works seamlessly for procedure names, inputs, and outputs.
- Lightweight runtime: No heavy schema parsing or query language — just HTTP calls under the hood.
- Familiar mental model: Writing a tRPC procedure feels like writing a regular function.
Setting Up a tRPC Project
Let's build a small application to demonstrate tRPC in action. We'll use Express on the server side and a vanilla TypeScript client, though tRPC also integrates smoothly with Next.js, Fastify, and other frameworks.
Installing Dependencies
Create a new project directory and install the required packages:
npm init -y
npm install @trpc/server @trpc/client @trpc/server/adapters/express express zod
npm install -D typescript @types/express @types/node tsx
npx tsc --init
We include zod because tRPC pairs naturally with it for runtime input validation that also produces static types.
Defining the Server
Creating the Router
The core building block of a tRPC server is the router. A router is a collection of procedures, and each procedure is either a query, mutation, or subscription. Let's create a simple user management API.
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// A mock database for demonstration purposes
interface User {
id: string;
name: string;
email: string;
}
const users: User[] = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com' },
{ id: '2', name: 'Bob Smith', email: 'bob@example.com' },
];
const t = initTRPC.create();
export const appRouter = t.router({
// Query: fetch all users
getUsers: t.procedure
.output(z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})))
.query(() => {
return users;
}),
// Query: fetch a single user by ID
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const user = users.find((u) => u.id === input.id);
if (!user) {
throw new Error('User not found');
}
return user;
}),
// Mutation: create a new user
createUser: t.procedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(({ input }) => {
const newUser: User = {
id: String(users.length + 1),
name: input.name,
email: input.email,
};
users.push(newUser);
return newUser;
}),
});
export type AppRouter = typeof appRouter;
Notice the final line: export type AppRouter = typeof appRouter;. This exported type is the bridge between server and client. The client imports it as a type only — no runtime code is shipped — and uses it to infer the shapes of all procedures.
Wiring Up the Express Adapter
tRPC provides adapters for popular HTTP frameworks. Here we use the Express adapter to mount our router at the /trpc endpoint.
// server/index.ts
import express from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from './router';
const app = express();
app.use(express.json());
app.use(
'/trpc',
createExpressMiddleware({
router: appRouter,
})
);
app.listen(3000, () => {
console.log('tRPC server running at http://localhost:3000');
});
Run the server with npx tsx server/index.ts. You now have a fully functional tRPC backend.
Building the Client
Creating a Typed Client
On the client side, you create a tRPC client that is parameterized with the AppRouter type. This is where the magic happens: TypeScript now knows every procedure, its input shape, and its output shape.
// client/index.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
async function main() {
// Fetch all users — fully typed return value
const allUsers = await trpc.getUsers.query();
console.log('All users:', allUsers);
// Fetch a single user — input is validated at compile time
const user = await trpc.getUserById.query({ id: '1' });
console.log('Single user:', user);
// Create a new user — TypeScript checks the input shape
const newUser = await trpc.createUser.mutate({
name: 'Charlie Brown',
email: 'charlie@example.com',
});
console.log('Created user:', newUser);
}
main().catch(console.error);
Try changing { id: '1' } to { id: 1 } (a number instead of a string). TypeScript will immediately flag the error because the getUserById procedure expects a string. This is the kind of safety tRPC provides throughout your entire stack.
Adding Context for Authentication
Real applications need authentication and request-scoped data. tRPC handles this through context, which is passed to every procedure. Let's extend our server to support authenticated operations.
// server/context.ts
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
export interface Context {
user: { id: string; name: string } | null;
}
export async function createContext(
opts: CreateExpressContextOptions
): Promise<Context> {
const authHeader = opts.req.headers.authorization;
if (!authHeader) {
return { user: null };
}
// In production, verify a real JWT or session token here
const token = authHeader.replace('Bearer ', '');
if (token === 'secret-token') {
return { user: { id: 'admin', name: 'Admin User' } };
}
return { user: null };
}
Now update the router initialization to use the context type and add a protected procedure:
// server/router.ts (updated)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import type { Context } from './context';
interface User {
id: string;
name: string;
email: string;
}
const users: User[] = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com' },
];
const t = initTRPC.context<Context>().create();
// Middleware that enforces authentication
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new Error('UNAUTHORIZED');
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const appRouter = t.router({
getUsers: t.procedure
.query(() => users),
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return users.find((u) => u.id === input.id) ?? null;
}),
// Only authenticated users can create users
createUser: protectedProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
}))
.mutation(({ input, ctx }) => {
console.log(`Created by: ${ctx.user!.name}`);
const newUser: User = {
id: String(users.length + 1),
name: input.name,
email: input.email,
};
users.push(newUser);
return newUser;
}),
});
export type AppRouter = typeof appRouter;
Update the Express setup to pass the context function:
// server/index.ts (updated)
import express from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from './router';
import { createContext } from './context';
const app = express();
app.use(express.json());
app.use(
'/trpc',
createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.listen(3000, () => {
console.log('tRPC server running at http://localhost:3000');
});
On the client, you can now pass authentication headers:
// client/index.ts (updated)
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
headers: () => ({
Authorization: 'Bearer secret-token',
}),
}),
],
});
async function main() {
const newUser = await trpc.createUser.mutate({
name: 'Diana Prince',
email: 'diana@example.com',
});
console.log('Created:', newUser);
}
main().catch(console.error);
Organizing Routers with Merging
As your application grows, a single router file becomes unwieldy. tRPC lets you split routers by domain and merge them into one root router.
// server/routers/user.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import type { Context } from '../context';
const t = initTRPC.context<Context>().create();
export const userRouter = t.router({
list: t.procedure.query(() => {
return [{ id: '1', name: 'Alice' }];
}),
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: 'Alice' };
}),
});
// server/routers/post.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import type { Context } from '../context';
const t = initTRPC.context<Context>().create();
export const postRouter = t.router({
list: t.procedure.query(() => {
return [{ id: '1', title: 'Hello tRPC' }];
}),
create: t.procedure
.input(z.object({ title: z.string().min(1), body: z.string() }))
.mutation(({ input }) => {
return { id: '2', ...input };
}),
});
// server/router.ts (merged)
import { initTRPC } from '@trpc/server';
import type { Context } from './context';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
const t = initTRPC.context<Context>().create();
export const appRouter = t.router({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
On the client, procedures are now namespaced:
// Accessing merged routers on the client
const users = await trpc.user.list.query();
const posts = await trpc.post.list.query();
const newPost = await trpc.post.create.mutate({
title: 'My First Post',
body: 'This is the content.',
});
Error Handling
tRPC has a built-in error system that preserves type information on the client. You can throw TRPCError instances with specific codes that the client can switch on.
// server/router.ts (with error handling)
import { TRPCError } from '@trpc/server';
export const appRouter = t.router({
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const user = users.find((u) => u.id === input.id);
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User with id ${input.id} not found`,
});
}
return user;
}),
});
On the client, you can catch and inspect these errors:
// client/error-handling.ts
import { TRPCClientError } from '@trpc/client';
try {
const user = await trpc.getUserById.query({ id: '999' });
} catch (error) {
if (error instanceof TRPCClientError) {
console.error('Error code:', error.data?.code);
console.error('Message:', error.message);
}
}
Best Practices
Validate All Inputs with Zod
Never trust client input. Always use Zod schemas on every procedure that accepts input. This gives you both runtime validation and compile-time types in one declaration.
Use Strict Output Schemas When Possible
While tRPC infers output types automatically, explicitly defining output schemas with Zod adds an extra layer of safety. It prevents accidental data leaks and ensures the server never returns unexpected shapes.
Keep Context Lightweight
Context is created on every request. Avoid expensive operations in the context factory. If you need to perform costly setup, cache it or defer it until actually needed inside a procedure.
Namespace by Domain
Use merged routers to organize procedures by domain (user, post, comment, billing). This keeps the client API clean and makes it obvious which area of the application a procedure belongs to.
Handle Errors Consistently
Use TRPCError with appropriate codes (NOT_FOUND, UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, INTERNAL_SERVER_ERROR) rather than throwing generic Error objects. This gives clients a reliable way to handle different failure modes.
Use Transformers for Date Handling
JSON serialization does not preserve Date objects. Use superjson as a transformer to automatically handle dates and other non-JSON-serializable types across the wire.
// server with superjson
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
import type { Context } from './context';
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
// client with superjson
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../server/router';
const trpc = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
Consider tRPC with Next.js for Full-Stack Apps
If you are building a React application, the @trpc/react-query package integrates tRPC with React Query, giving you automatic caching, refetching, and optimistic updates with full type safety. This is the most popular way to use tRPC in production.
Conclusion
tRPC represents a paradigm shift for TypeScript full-stack development. By leveraging the type system as the single source of truth, it eliminates an entire class of bugs caused by API contract drift between frontend and backend. You write functions on the server, call them on the client, and the compiler guarantees everything lines up — no schema files, no code generation step, no manual synchronization. When combined with Zod for validation, superjson for serialization, and a framework like React Query on the frontend, tRPC provides one of the most productive and type-safe developer experiences available in the TypeScript ecosystem today. Whether you are building a small internal tool or a large production application, tRPC is well worth considering as your API layer.