tRPC Tutorial: Learn Type-Safe APIs from Scratch (2026)
tRPC is a TypeScript RPC framework that lets you build type-safe APIs without code generation or schema definitions. Created by Alex Johansson, tRPC infers types from your server functions and exposes them to the client, giving you full end-to-end type safety — the client knows exactly what arguments a procedure expects and what data it returns, all inferred from the server code.
If you change a server procedure, TypeScript immediately shows errors in all client code that uses it. tRPC works with Express, Fastify, Next.js, and standalone servers. While developed with React in mind, tRPC supports Vue, Svelte, and plain TypeScript clients.
Server Setup and Router Definition
tRPC servers start with a router containing procedures (queries and mutations). A procedure is a function with input validation and a resolver. Use @trpc/server’s initTRPC to create a router instance. Queries read data, mutations write data. Input validation uses Zod schemas for type inference.
tRPC v11 uses the /api/trpc endpoint by default. The createContext function provides shared state to all procedures. The router is served via an HTTP handler compatible with Node.js, Express, or Next.js.
// server/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
// Context type
export async function createContext({ req }) {
const token = req.headers.get('authorization')?.split(' ')[1];
const user = token ? await getUserFromToken(token) : null;
return { user };
}
const t = initTRPC.context().create();
const publicProcedure = t.procedure;
const authedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Router definition
const appRouter = t.router({
hello: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello, ${input.name}!` };
}),
post: t.router({
list: publicProcedure
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ input }) => {
const posts = await db.posts.findMany({ take: input.limit });
return { posts };
}),
byId: publicProcedure
.input(z.string())
.query(async ({ input }) => {
const post = await db.posts.findUnique({ where: { id: input } });
if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
return post;
}),
create: authedProcedure
.input(z.object({ title: z.string().min(3), body: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await db.posts.create({ data: { ...input, authorId: ctx.user.id } });
return post;
}),
}),
});
export type AppRouter = typeof appRouter;
const server = createHTTPServer({ router: appRouter, createContext });
server.listen(3000);
Client Setup and Type Inference
The tRPC client automatically infers the full API type from the server’s AppRouter type. Import createTRPCReact (React) or createTRPCClient (vanilla) and pass the router type. All procedures become type-checked function calls.
The createTRPCReact function returns hooks: useQuery, useMutation, and useUtils. The client connects via httpBatchLink (batches requests automatically) or httpLink for individual requests.
// client/src/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/src/trpc';
export const trpc = createTRPCReact();
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000',
headers() {
const token = localStorage.getItem('token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
});
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc, trpcClient } from './trpc';
const queryClient = new QueryClient();
export default function App() {
return (
);
}
// Using the client (fully type-safe)
function Home() {
const posts = trpc.post.list.useQuery({ limit: 10 });
const createPost = trpc.post.create.useMutation({
onSuccess: () => posts.refetch(),
});
}
Queries and Mutations with React
tRPC hooks integrate with TanStack React Query. useQuery fetches data on mount and caches it. useMutation sends mutations. tRPC procedures are fully typed — the input and output types are inferred from the server.
useUtils provides cache manipulation: invalidate, refetch, setQueryData, and getQueryData for optimistic updates. tRPC’s query keys are based on the procedure path and input.
import { trpc } from '../trpc';
function PostList() {
const postsQuery = trpc.post.list.useQuery(
{ limit: 20 },
{ staleTime: 30 * 1000, refetchInterval: 60 * 1000 }
);
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: (newPost) => {
// Invalidate all post queries
utils.post.list.invalidate();
// Or optimistically update cache
utils.post.list.setQueryData({ limit: 20 }, (old) => ({
posts: [...(old?.posts || []), newPost],
}));
},
onError: (error) => {
console.error('Create failed:', error.message);
},
});
if (postsQuery.isLoading) return Loading...;
if (postsQuery.error) return Error: {postsQuery.error.message};
return (
{postsQuery.data.posts.map((post) => (
))}
);
}
Middleware and Authorization
tRPC procedures can use middleware via .use() for authentication, logging, rate limiting, or any cross-cutting concern. Middleware receives the current context and can return modified context for downstream procedures.
The TRPCError class standardizes error responses with codes like UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and BAD_REQUEST. Auth middleware checks ctx.user and throws if unauthorized.
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context().create();
// Logging middleware
const logger = t.middleware(async ({ path, type, next, rawInput }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`[${type}] ${path} - ${duration}ms`);
if (!result.ok) console.error(`Error in ${path}:`, result.error);
return result;
});
// Role-based middleware
const requireRole = (role) =>
t.middleware(async ({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
if (ctx.user.role !== role) throw new TRPCError({ code: 'FORBIDDEN' });
return next({ ctx: { ...ctx, user: ctx.user } });
});
const publicProcedure = t.procedure.use(logger);
const adminProcedure = t.procedure.use(logger).use(requireRole('admin'));
export const appRouter = t.router({
health: publicProcedure.query(() => ({ status: 'ok' })),
admin: t.router({
deletePost: adminProcedure
.input(z.string())
.mutation(async ({ input }) => {
await db.posts.delete({ where: { id: input } });
return { deleted: true };
}),
}),
});
Subscriptions and Real-Time Updates
tRPC subscriptions enable real-time communication via WebSocket or Server-Sent Events. Subscriptions use the t.procedure.subscription() method. The server returns an async iterable or uses a pub/sub pattern.
On the client, useSubscription hook provides live data streams. tRPC subscriptions integrate with any pub/sub backend. The wsLink handles WebSocket connections with automatic reconnection.
// Server subscription
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
// Add this to your router
t.router({
onPostCreated: t.procedure
.subscription(() => {
return observable((emit) => {
const handler = (post) => {
emit.next(post);
};
ee.on('postCreated', handler);
return () => ee.off('postCreated', handler);
});
}),
// Mutation that emits
createPost: authedProcedure
.input(z.object({ title: z.string(), body: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await db.posts.create({ data: { ...input, authorId: ctx.user.id } });
ee.emit('postCreated', post);
return post;
}),
});
// Client subscription
function LivePosts() {
trpc.onPostCreated.useSubscription(undefined, {
onData: (post) => console.log('New post:', post.title),
onError: (err) => console.error('Subscription error:', err),
});
return Listening for new posts...;
}
Error Handling and Validation
tRPC errors are typed — the client knows the error shape. TRPCError with codes like NOT_FOUND, UNAUTHORIZED, and VALIDATION_ERROR maps to appropriate HTTP status codes. Zod validation errors are automatically serialized and sent to the client.
On the client, the error object contains shape, data, and the original TRPCError. You can format errors per-component or globally via the trpc client link.
// Server validation errors (automatic with Zod)
export const appRouter = t.router({
createUser: t.procedure
.input(z.object({
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
name: z.string().min(2, 'Name too short'),
}))
.mutation(async ({ input }) => {
return db.users.create({ data: input });
}),
});
// Client error handling
function CreateUserForm() {
const mutation = trpc.createUser.useMutation({
onError: (error) => {
if (error.data?.code === 'BAD_REQUEST') {
const fieldErrors = error.data?.zodError?.fieldErrors;
// fieldErrors?.email?.[0] — 'Invalid email'
// fieldErrors?.age?.[0] — 'Must be 18+'
}
if (error.data?.code === 'UNAUTHORIZED') {
navigate('/login');
}
},
});
return (
);
}
Frequently Asked Questions
How does tRPC differ from GraphQL?
tRPC provides the same end-to-end type safety as GraphQL but without a schema language or code generation. Types flow naturally from server to client.
Can I use tRPC with non-TypeScript clients?
tRPC is designed for TypeScript. For non-TypeScript clients, you need a separate API layer. tRPC’s strength is the end-to-end TypeScript experience.
Does tRPC work with Next.js?
Yes. tRPC has first-class Next.js support via @trpc/next and @trpc/react-query. Type safety extends across the full stack.
How does tRPC handle file uploads?
tRPC supports file uploads via FormData or base64 encoding. For large files, use a separate upload endpoint with a signed URL pattern.
Originally published on Ayodhyyya. Last updated June 1, 2026.