SutraCore/Build log/Development
Development

Building Type-Safe APIs with tRPC and Next.js

Learn how to build end-to-end type-safe APIs using tRPC in Next.js applications for improved developer experience and reliability.

SutraCore Team · authorNovember 20, 202511 min read
#tRPC#API#TypeScript#Next.js#Type Safety

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.

Share this article

Share on TwitterShare on LinkedIn