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.
JavaScriptTurborepo Advanced Patterns: Production-Ready Monorepo Architecture in 2025
Master production Turborepo patterns with real-world examples. Migration guide, CI/CD pipelines, remote caching, testing strategies, and framework-specific configs.
JavaScriptBuild a Dynamic OG Image Generator for Your Blog (Next.js + Screenshot API)
Stop manually creating social preview images. Learn how to automatically generate unique Open Graph images for every blog post using Next.js and a screenshot API.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.