Building Type-Safe APIs with tRPC
Prerequisites
- TypeScript 5+
- React or Next.js
- A basic understanding of Zod
- The emotional resilience to never write
fetch('/api/...')again - At least one mass extinction event caused by a mistyped API response
- A mass-produced mug with “I <3 REST” on it, ready for the bin
What We’re Building
A full-stack TypeScript application with tRPC. Your IDE knows your API types. No OpenAPI schemas, no code generation, no runtime type mismatches. Just you, your compiler, and a quiet confidence that your API calls actually match what the server expects.
If youve ever spent forty minutes debugging a 500 error only to realise you spelled userId as UserId, this one’s for you.

The Approach
- Set up tRPC server
- Define procedures
- Add authentication context
- Create client
- Use in React
Nothing groundbreaking on paper. The magic is that every single step shares the same types, end to end, without you lifting a finger. Well, you do lift a few fingers. Typing is involved. But you know what I mean.
Step 1: Install Dependencies
Lets get the boring bit out of the way first.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
That’s a fair few packages, but every one of them earns its keep. React Query alone is worth the price of admission.
Step 2: Create the Router
This is where the real work begins. We initialise tRPC with a context that carries our session and Prisma client through every request.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { z } from 'zod';
export async function createContext(opts: CreateNextContextOptions) {
const session = await getSession(opts.req);
return {
session,
prisma,
};
}
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session,
user: ctx.session.user,
},
});
});
The protectedProcedure middleware is beautifully simple. If theres no session, you get a 401. If there is, the ctx gets narrowed so that ctx.user is guaranteed to exist downstream. TypeScript just knows. No casting, no ! assertions, no prayers.
Step 3: Define Procedures
Now we write the actual API. Notice how Zod handles input validation and tRPC infers the types from it automatically. You define the shape once and it flows everywhere.
// server/routers/users.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const usersRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
}),
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
select: { id: true, name: true },
});
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return user;
}),
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
});
That .input(z.object({ id: z.string().uuid() })) line is doing a staggering amount of work. It validates at runtime and provides compile time types for the client. If someone passes { id: 42 }, Zod rejects it before your database even hears about it.
Now the posts router, which includes cursor based pagination because we’re not animals.
// server/routers/posts.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const postsRouter = router({
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const posts = await ctx.prisma.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return { posts, nextCursor };
}),
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});
The take: input.limit + 1 trick is a classic. Fetch one extra record, and if it exists, you know there’s another page. Pop it off before returning. Simple, efficient, no COUNT(*) queries needed.
Step 4: Combine Routers
// server/routers/_app.ts
import { router } from '../trpc';
import { usersRouter } from './users';
import { postsRouter } from './posts';
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
});
export type AppRouter = typeof appRouter;
That AppRouter type export is the entire secret. It’s the single source of truth that your client will consume. No generated files, no build step, just TypeScript doing what TypeScript does best.
Step 5: Create the Client
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
Three lines. That’s it. Your entire type-safe client, ready to go.

// utils/trpc-client.ts
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc';
export function getTRPCClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
authorization: getAuthToken(),
};
},
}),
],
});
}
The httpBatchLink is worth highlighting. If your component fires three tRPC calls on mount, they get batched into a single HTTP request automatically. Your network tab stays clean, your server stays happy.
Step 6: Provider Setup
Standard React provider ceremony. You’ve done this dance before.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { trpc, getTRPCClient } from '../utils/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() => getTRPCClient());
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
The useState(() => new QueryClient()) pattern ensures we dont create a new client on every render. It’s a subtle thing, but getting this wrong leads to some truly baffling cache invalidation bugs.
Step 7: Use in Components
Here’s where it all pays off. Look at that useInfiniteQuery call. Every property on post, every field on post.author, fully typed. Autocomplete just works. Rename a field on the server and your frontend lights up red instantly.
// components/PostList.tsx
'use client';
import { trpc } from '../utils/trpc';
export function PostList() {
const { data, isLoading, fetchNextPage, hasNextPage } =
trpc.posts.list.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.pages.flatMap((page) =>
page.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load More</button>
)}
</div>
);
}
And the create form, with optimistic cache invalidation baked right in.
// components/CreatePost.tsx
'use client';
import { trpc } from '../utils/trpc';
export function CreatePost() {
const utils = trpc.useUtils();
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
utils.posts.list.invalidate();
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
{createPost.error && <p>Error: {createPost.error.message}</p>}
</form>
);
}
That utils.posts.list.invalidate() on success is the chef’s kiss moment. Create a post, the list refetches. No manual state management, no Redux, no tears.

Step 8: Error Handling
tRPC gives you structured error codes that map to HTTP status codes. FORBIDDEN becomes a 403, NOT_FOUND becomes a 404, and so on. Your error handling isnt some ad hoc string comparison anymore.
// server/routers/users.ts
import { TRPCError } from '@trpc/server';
export const usersRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only admins can delete users',
});
}
try {
await ctx.prisma.user.delete({ where: { id: input.id } });
} catch (e) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete user',
cause: e,
});
}
}),
});
Wrapping the Prisma call in a try/catch and rethrowing as a TRPCError keeps your error responses consistent. The cause field preserves the original error for logging without leaking internals to the client.
The Result
- Complete type safety from database to UI
- No API documentation needed. Types are the docs.
- Automatic input validation via Zod
- Batched requests out of the box
- React Query integration with caching, refetching, and infinite queries for free

What I’d Do Differently
Set up proper error boundaries for tRPC errors from the start. The default behaviour when a mutation fails doesnt always produce something you’d want users to see, and bolting it on after the fact always feels like an afterthought. Because it is one.
tRPC eliminates an entire class of bugs. If your frontend compiles, your API calls are correct. Thats the whole pitch, and it delivers.