tRPC: End-to-End Type-Safe APIs for TypeScript
Master tRPC for full-stack TypeScript applications. Learn procedures, routers, React Query integration, and build type-safe APIs without schemas.
Moshiour Rahman
Advertisement
What is tRPC?
tRPC enables end-to-end type-safe APIs without code generation or schemas. Your TypeScript types flow from backend to frontend, catching errors at compile time.
tRPC Benefits
| Feature | Description |
|---|---|
| Type Safety | Full type inference |
| No Codegen | No schema files needed |
| Autocompletion | IDE support everywhere |
| Validation | Zod integration |
| React Query | Built-in integration |
Getting Started
Installation
# Server
npm install @trpc/server zod
# Client
npm install @trpc/client @trpc/react-query @tanstack/react-query
Server Setup
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
Define Context
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
export async function createContext(opts: CreateNextContextOptions) {
const session = await getSession({ req: opts.req });
return {
session,
prisma, // Database client
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
Procedures
Basic Procedures
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
// Query - GET data
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
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 - Change data
create: publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email()
}))
.mutation(async ({ input, ctx }) => {
const user = await ctx.prisma.user.create({
data: input
});
return user;
}),
// Protected procedure
updateProfile: protectedProcedure
.input(z.object({
name: z.string().optional(),
bio: z.string().optional()
}))
.mutation(async ({ input, ctx }) => {
return ctx.prisma.user.update({
where: { id: ctx.session.user.id },
data: input
});
}),
// List with pagination
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish()
}))
.query(async ({ input, ctx }) => {
const { limit, cursor } = input;
const users = await ctx.prisma.user.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' }
});
let nextCursor: typeof cursor = undefined;
if (users.length > limit) {
const nextItem = users.pop();
nextCursor = nextItem!.id;
}
return { users, nextCursor };
})
});
Input Validation
import { z } from 'zod';
// Complex input schema
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).max(5),
metadata: z.object({
seoTitle: z.string().optional(),
seoDescription: z.string().optional()
}).optional()
});
export const postRouter = router({
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ input, ctx }) => {
return ctx.prisma.post.create({
data: {
...input,
authorId: ctx.session.user.id
}
});
})
});
// Infer types from schema
type CreatePostInput = z.infer<typeof createPostSchema>;
Middleware
// server/trpc.ts
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session
}
});
});
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.session?.user?.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
});
// Logging middleware
const logger = 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;
});
export const protectedProcedure = t.procedure.use(isAuthed);
export const adminProcedure = t.procedure.use(isAuthed).use(isAdmin);
export const loggedProcedure = t.procedure.use(logger);
Root Router
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { commentRouter } from './comment';
export const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter
});
export type AppRouter = typeof appRouter;
Client Setup
React Client
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
Provider Setup
// pages/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: getAuthToken()
};
}
})
]
});
function App({ Component, pageProps }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</trpc.Provider>
);
}
Using tRPC in Components
Queries
import { trpc } from '../utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
function UserList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
trpc.user.list.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor
}
);
return (
<div>
{data?.pages.map((page) =>
page.users.map((user) => (
<div key={user.id}>{user.name}</div>
))
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Mutations
import { trpc } from '../utils/trpc';
function CreateUserForm() {
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.list.invalidate();
},
onError: (error) => {
alert(error.message);
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createUser.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
Optimistic Updates
function TodoList() {
const utils = trpc.useUtils();
const toggleTodo = trpc.todo.toggle.useMutation({
onMutate: async ({ id }) => {
// Cancel outgoing refetches
await utils.todo.list.cancel();
// Snapshot previous value
const previousTodos = utils.todo.list.getData();
// Optimistically update
utils.todo.list.setData(undefined, (old) =>
old?.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
// Rollback on error
utils.todo.list.setData(undefined, context?.previousTodos);
},
onSettled: () => {
// Refetch after error or success
utils.todo.list.invalidate();
}
});
// ...
}
Error Handling
import { TRPCError } from '@trpc/server';
// Server-side errors
export const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.prisma.post.findUnique({
where: { id: input.id }
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found'
});
}
if (post.authorId !== ctx.session.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own posts'
});
}
return ctx.prisma.post.delete({
where: { id: input.id }
});
})
});
// Client-side error handling
function DeleteButton({ postId }: { postId: string }) {
const deletePost = trpc.post.delete.useMutation({
onError: (error) => {
if (error.data?.code === 'FORBIDDEN') {
alert('You cannot delete this post');
} else {
alert(error.message);
}
}
});
return (
<button onClick={() => deletePost.mutate({ id: postId })}>
Delete
</button>
);
}
Next.js Integration
API Route Handler
// pages/api/trpc/[trpc].ts
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 }) {
console.error('tRPC error:', error);
}
});
Server-Side Rendering
// pages/user/[id].tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '../../server/routers/_app';
import { createContext } from '../../server/context';
import superjson from 'superjson';
export async function getServerSideProps(context) {
const helpers = createServerSideHelpers({
router: appRouter,
ctx: await createContext(context),
transformer: superjson
});
const id = context.params?.id as string;
await helpers.user.getById.prefetch({ id });
return {
props: {
trpcState: helpers.dehydrate(),
id
}
};
}
function UserPage({ id }: { id: string }) {
// This will be instantly available since we prefetched
const { data } = trpc.user.getById.useQuery({ id });
return <div>{data?.name}</div>;
}
Summary
| Feature | Usage |
|---|---|
| Query | trpc.router.procedure.useQuery() |
| Mutation | trpc.router.procedure.useMutation() |
| Infinite | useInfiniteQuery() |
| Invalidate | utils.router.procedure.invalidate() |
| Prefetch | helpers.router.procedure.prefetch() |
tRPC provides seamless end-to-end type safety for TypeScript full-stack applications.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Next.js 14 Tutorial: Complete Guide with App Router
Master Next.js 14 with App Router. Learn server components, data fetching, routing, server actions, and build full-stack React applications.
JavaScriptshadcn/ui: Build Beautiful React Components
Master shadcn/ui for React applications. Learn component installation, customization, theming, and build accessible, beautiful user interfaces.
JavaScriptReact Hooks Complete Guide: useState to Custom Hooks
Master all React hooks from basics to advanced. Learn useState, useEffect, useContext, useReducer, useMemo, useCallback, and create custom hooks.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.