tRPC enables you to build fully type-safe APIs without code generation. Learn how to leverage tRPC for better developer experience and fewer runtime errors.
What is tRPC?
tRPC allows you to build type-safe APIs with these benefits:
- End-to-end type safety - Share types between client and server
- No code generation - Types are inferred automatically
- Excellent DX - Autocomplete and type checking
- Framework agnostic - Works with any TypeScript project
- Lightweight - Minimal runtime overhead
Setup
Installation
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query
Server Setup
Create your tRPC router:
// server/trpc.ts import { initTRPC } from '@trpc/server'; import { type Context } from './context'; 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) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); });
Define Routers
Create type-safe API endpoints:
// server/routers/user.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc'; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { return await ctx.db.user.findUnique({ where: { id: input.id }, }); }), update: protectedProcedure .input(z.object({ name: z.string().min(1), email: z.string().email(), })) .mutation(async ({ input, ctx }) => { return await ctx.db.user.update({ where: { id: ctx.session.user.id }, data: input, }); }), list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), })) .query(async ({ input, ctx }) => { const users = await ctx.db.user.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, }); let nextCursor: string | undefined; if (users.length > input.limit) { const nextItem = users.pop(); nextCursor = nextItem!.id; } return { users, nextCursor, }; }), });
App Router
Combine routers:
// server/routers/_app.ts import { router } from '../trpc'; import { userRouter } from './user'; import { postRouter } from './post'; import { workspaceRouter } from './workspace'; export const appRouter = router({ user: userRouter, post: postRouter, workspace: workspaceRouter, }); export type AppRouter = typeof appRouter;
Client Setup
React Query Provider
Set up the tRPC client:
// app/providers.tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { trpc } from '@/lib/trpc'; import { useState } from 'react'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', }), ], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ); }
Using tRPC in Components
Type-safe API calls in React components:
'use client'; import { trpc } from '@/lib/trpc'; export function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId }); const updateUser = trpc.user.update.useMutation(); if (isLoading) return <Spinner />; if (!user) return <div>User not found</div>; const handleUpdate = async () => { await updateUser.mutateAsync({ name: 'New Name', email: 'new@email.com', }); }; return ( <div> <h1>{user.name}</h1> <button onClick={handleUpdate}>Update</button> </div> ); }
Advanced Patterns
Infinite Queries
Implement pagination:
const { data, fetchNextPage, hasNextPage } = trpc.user.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor, } );
Optimistic Updates
Update UI before server response:
const utils = trpc.useUtils(); const updateUser = trpc.user.update.useMutation({ onMutate: async (newData) => { await utils.user.getById.cancel(); const previousData = utils.user.getById.getData({ id: userId }); utils.user.getById.setData({ id: userId }, (old) => ({ ...old!, ...newData, })); return { previousData }; }, onError: (err, newData, context) => { utils.user.getById.setData({ id: userId }, context?.previousData); }, });
Subscriptions
Real-time updates with WebSockets:
export const postRouter = router({ onAdd: publicProcedure.subscription(() => { return observable<Post>((emit) => { const onAdd = (data: Post) => emit.next(data); eventEmitter.on('add', onAdd); return () => eventEmitter.off('add', onAdd); }); }), });
Error Handling
Implement proper error handling:
const { data, error } = trpc.user.getById.useQuery( { id: userId }, { retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), onError: (error) => { if (error.data?.code === 'UNAUTHORIZED') { router.push('/login'); } }, } );
Conclusion
tRPC provides unmatched type safety and developer experience for building APIs. By eliminating the gap between client and server, you can build more reliable applications faster.
See tRPC in action in our SaaS boilerplate.