Intermediate
25 min
Full Guide
tRPC - End-to-End Type Safety
Build fully typesafe APIs without schemas or code generation using tRPC
What is tRPC?
tRPC (TypeScript Remote Procedure Call) enables you to build fully typesafe APIs without schemas or code generation. It leverages TypeScript's type inference to automatically share types between your server and client.
If you're building a TypeScript full-stack application (like Next.js), tRPC eliminates the traditional API layer complexity while providing complete type safety.
✨ Why tRPC?
🔒
End-to-End Type Safety
Types flow from server to client automatically
⚡
No Code Generation
No build step, instant type updates
🎯
Autocomplete Everywhere
Full IDE support for API calls
🚀
Tiny Bundle
~2KB client, no runtime overhead
Setting Up tRPC
// Installation
// npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// 1. Define your router (server/trpc.ts)
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// Initialize tRPC
const t = initTRPC.context().create();
// Export reusable router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
// 2. Create your app router (server/routers/_app.ts)
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// Export type definition of API
export type AppRouter = typeof appRouter;
Defining Procedures
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
export const userRouter = router({
// Query - for fetching data (like GET)
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id }
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found'
});
}
return user;
}),
// Query with no input
list: publicProcedure
.query(async ({ ctx }) => {
return ctx.db.user.findMany({
take: 10,
orderBy: { createdAt: 'desc' }
});
}),
// Mutation - for changing data (like POST/PUT/DELETE)
create: protectedProcedure
.input(z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user']).optional().default('user')
}))
.mutation(async ({ input, ctx }) => {
// ctx.user is available because of protectedProcedure
const user = await ctx.db.user.create({
data: {
...input,
createdBy: ctx.user.id
}
});
return user;
}),
// Update mutation
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
email: z.string().email().optional()
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.user.update({
where: { id },
data
});
}),
// Delete mutation
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.user.delete({
where: { id: input.id }
});
return { success: true };
})
});
Context & Middleware
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { getSession } from 'next-auth/react';
export async function createContext({ req, res }) {
const session = await getSession({ req });
return {
req,
res,
user: session?.user ?? null,
db: prisma // Your database client
};
}
export type Context = inferAsyncReturnType;
// Middleware for protected routes
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in'
});
}
return next({
ctx: {
// Infers the user is non-null
user: ctx.user
}
});
});
// Logging middleware
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`${type} ${path} - ${duration}ms`);
return result;
});
// Apply middleware
export const loggedProcedure = t.procedure.use(loggerMiddleware);
React Client with React Query
// utils/trpc.ts - Client setup
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact();
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export function TRPCProvider({ children }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
authorization: getAuthToken()
};
}
})
]
})
);
return (
{children}
);
}
Using tRPC in Components
// components/UserList.tsx
import { trpc } from '../utils/trpc';
export function UserList() {
// Queries - automatic caching & refetching
const { data: users, isLoading, error } = trpc.user.list.useQuery();
// Query with input
const { data: user } = trpc.user.getById.useQuery(
{ id: '123' },
{ enabled: !!userId } // Only run if userId exists
);
// Mutations
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidate cache to refetch
utils.user.list.invalidate();
},
onError: (error) => {
alert(error.message);
}
});
const handleSubmit = (data) => {
createUser.mutate({
name: data.name,
email: data.email,
// TypeScript knows exactly what fields are required!
});
};
if (isLoading) return Loading...;
if (error) return Error: {error.message};
return (
{users?.map(user => (
{user.name} - {user.email}
))}
);
}
// Optimistic updates
const updateUser = trpc.user.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.user.getById.cancel({ id: newData.id });
// Snapshot current value
const previousUser = utils.user.getById.getData({ id: newData.id });
// Optimistically update
utils.user.getById.setData({ id: newData.id }, (old) => ({
...old,
...newData
}));
return { previousUser };
},
onError: (err, newData, context) => {
// Rollback on error
utils.user.getById.setData(
{ id: newData.id },
context?.previousUser
);
}
});
Next.js Integration
// pages/api/trpc/[trpc].ts (Pages Router)
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/context';
export default createNextApiHandler({
router: appRouter,
createContext,
onError: ({ error, type, path }) => {
console.error(`tRPC Error on ${path}:`, error);
}
});
// app/api/trpc/[trpc]/route.ts (App Router)
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext
});
export { handler as GET, handler as POST };
// Server Components (App Router) - Direct calls!
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';
export default async function UsersPage() {
// Call tRPC directly on server - no HTTP!
const caller = appRouter.createCaller(await createContext());
const users = await caller.user.list();
return (
{users.map(user => (
{user.name}
))}
);
}
Subscriptions (Real-time)
// Server - WebSocket subscriptions
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const postRouter = router({
onNewPost: publicProcedure
.subscription(() => {
return observable((emit) => {
const onAdd = (post: Post) => emit.next(post);
ee.on('newPost', onAdd);
return () => {
ee.off('newPost', onAdd);
};
});
}),
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.create({ data: input });
// Emit to subscribers
ee.emit('newPost', post);
return post;
})
});
// Client - Subscribe to updates
import { trpc } from '../utils/trpc';
function LiveFeed() {
const [posts, setPosts] = useState([]);
trpc.post.onNewPost.useSubscription(undefined, {
onData: (post) => {
setPosts((prev) => [post, ...prev]);
},
onError: (err) => {
console.error('Subscription error:', err);
}
});
return (
{posts.map(post => (
{post.title}
))}
);
}
Error Handling
// Server-side errors
import { TRPCError } from '@trpc/server';
// Built-in error codes
throw new TRPCError({
code: 'BAD_REQUEST', // 400
code: 'UNAUTHORIZED', // 401
code: 'FORBIDDEN', // 403
code: 'NOT_FOUND', // 404
code: 'CONFLICT', // 409
code: 'UNPROCESSABLE_CONTENT', // 422
code: 'TOO_MANY_REQUESTS', // 429
code: 'INTERNAL_SERVER_ERROR', // 500
message: 'Custom error message',
cause: originalError // Optional
});
// Client-side handling
const mutation = trpc.user.create.useMutation({
onError: (error) => {
// error.data contains the error code
if (error.data?.code === 'CONFLICT') {
toast.error('User already exists');
} else if (error.data?.code === 'UNAUTHORIZED') {
router.push('/login');
} else {
toast.error(error.message);
}
}
});
// Zod validation errors are automatic!
// If input doesn't match schema, client gets detailed errors
tRPC vs REST vs GraphQL
| Feature | REST | GraphQL | tRPC |
|---|---|---|---|
| Type Safety | Manual | Codegen | Automatic ✓ |
| Schema | OpenAPI | SDL | None needed ✓ |
| Build Step | Optional | Required | None ✓ |
| Language | Any | Any | TypeScript only |
| Learning Curve | Low | Medium | Low ✓ |
💡 tRPC Best Practices
- ✓ Use Zod for validation - Input validation and type inference
- ✓ Split routers by domain - Keep routers focused and organized
- ✓ Use middleware - Auth, logging, rate limiting
- ✓ Leverage React Query - Caching, optimistic updates, prefetching
- ✓ Error handling - Use TRPCError with proper codes
- ✓ Server components - Call tRPC directly without HTTP