Introduction
tRPC brings end-to-end type safety to your API without code generation or schema duplication. If you use TypeScript, tRPC provides the best developer experience for building APIs. This guide covers everything you need to know.
Why tRPC?
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ tRPC Value Proposition โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Traditional API Development: โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ Backend โโโโโโถโ JSON โโโโโโถโ Frontend โ โ
โ โ Types โ โ Types โ โ Types โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ โ โ โ
โ โโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโ โ
โ MANUALLY SYNCED! โ
โ Drift = Bugs! โ
โ โ
โ tRPC: โ
โ โโโโโโโโโโโโ โ
โ โ Backend โโโโโโถ Full Type Safety! โ
โ โ Types โ No Manual Sync Needed โ
โ โโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโถ Frontend Types โ
โ SHARED! โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Core Concepts
Key Terminology
tRPC_terms:
router:
- "Collection of API procedures"
- "Main entry point for your API"
procedure:
- "Individual API endpoint"
- "query, mutation, or subscription"
context:
- "Data available to all procedures"
- "Session, database connection, etc."
middleware:
- "Logic that runs before procedures"
- "Authentication, validation, etc."
Setting Up tRPC
Project Structure
src/
โโโ server/
โ โโโ api/
โ โ โโโ root.ts # Main router
โ โ โโโ trpc.ts # tRPC initialization
โ โ โโโ routers/ # Route handlers
โ โ โโโ user.ts
โ โ โโโ post.ts
โ โโโ db.ts # Database client
โโโ utils/
โ โโโ api.ts # Client-side tRPC setup
โโโ app/
โโโ page.tsx # Using the API
Server Setup
// src/server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await getServerSession(authOptions);
return {
prisma,
session,
...opts,
};
};
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;
Building Routers
Simple Router
// src/server/api/routers/user.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
// Query: Read data
getAll: publicProcedure.query(async ({ ctx }) => {
return ctx.prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
}),
// Query with input validation
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found'
});
}
return user;
}),
// Mutation: Create/Update/Delete
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.create({
data: {
...input,
createdBy: ctx.session.user.id,
},
});
}),
// Protected mutation (auth required)
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.prisma.user.update({
where: { id },
data,
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.user.delete({
where: { id: input.id },
});
}),
});
Combining Routers
// src/server/api/root.ts
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
import { createCallerFactory } from './trpc';
export const appRouter = createTRPCRouter({
user: userRouter,
post: postRouter,
});
export type AppRouter = typeof appRouter;
export const createCaller = createCallerFactory(appRouter);
Authentication with Middleware
Protected Procedure
// src/server/api/trpc.ts
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
Role-Based Access
// Middleware for role checking
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.session?.user?.role !== 'ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin access required'
});
}
return next({ ctx });
});
export const adminProcedure = t.procedure.use(isAdmin);
// Usage
adminProcedure.mutation(async ({ ctx }) => {
// Only admins can execute this
});
Client-Side Usage
Setup Provider
// src/utils/api.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import superjson from 'superjson';
import type { AppRouter } from '@/server/api/root';
export const api = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
api.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
})
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</api.Provider>
);
}
Using in Components
// src/app/users/page.tsx
import { api } from '@/utils/api';
export default function UsersPage() {
// Fully typed response!
const { data: users, isLoading } = api.user.getAll.useQuery();
// Mutation with typed input
const createUser = api.user.create.useMutation({
onSuccess: () => {
// Refetch users after create
void utils.user.getAll.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{users?.map(user => (
<div key={user.id}>{user.name}</div>
))}
<button
onClick={() => createUser.mutate({
name: 'New User',
email: '[email protected]'
})}
>
Add User
</button>
</div>
);
}
Error Handling
Custom Error Codes
// Throwing errors in procedures
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User with ID ${input.id} not found`,
cause: 'USER_NOT_FOUND',
});
}
return user;
});
// Error formatting (already in trpc.ts)
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
code: error.code,
cause: error.cause,
},
};
}
Testing tRPC
Unit Testing Procedures
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from '@/server/api/root';
import { createCaller } from '@/server/api/trpc';
describe('userRouter', () => {
let caller: ReturnType<typeof createCaller>;
beforeEach(() => {
// Create caller with mock context
caller = createCaller({
session: null,
prisma: mockPrisma,
});
});
it('getAll returns users', async () => {
const users = await caller.user.getAll();
expect(users).toHaveLength(2);
});
it('create creates a user', async () => {
const result = await caller.user.create({
name: 'Test',
email: '[email protected]',
});
expect(result.name).toBe('Test');
});
});
Key Takeaways
- Zero schema - TypeScript types are the schema
- Type safety - End-to-end without code generation
- Great DX - Full autocomplete, type hints
- React Query - Built on TanStack Query
- Flexible - Works with any backend, any frontend
Comments