tRPC Crash Course: End-to-End Type Safe APIs
Learn tRPC In 45 Minutes — Full Crash Course
tRPC (TypeScript Remote Procedure Call) is a framework that enables end-to-end type safety between your frontend and backend — with zero code generation and zero runtime overhead. Unlike REST or GraphQL, tRPC lets you call server functions directly from the client as if they were local, with full TypeScript autocomplete and type checking on both sides 2.
The core philosophy is simple: your API is just a collection of TypeScript functions. tRPC handles the serialization, routing, and network transport automatically, so you never write an API schema, never generate types, and never manually sync frontend types with backend types.
tRPC was created by Alexandra "KATT" Schaller and has rapidly become one of the most popular tools in the TypeScript ecosystem, especially within the T3 Stack. It's used in production by companies handling 2.4M+ requests/day with 99.97% uptime .
Footnotes
-
tRPC Official Site - Official tRPC documentation and quickstart guide ↩
-
tRPC Quickstart - Official quickstart: Your first tRPC API step by step ↩
-
Building Production-Ready tRPC APIs - InfoQ - Production migration story: 2.4M requests/day, 99.97% uptime, error formatting, and middleware patterns ↩
tRPC Evolution & Ecosystem
tRPC Born
2020Initial release as an experimental TypeScript RPC framework. Focused on Next.js integration and proving end-to-end type safety was viable without GraphQL-style schemas."
v9 Breakthrough
2021Major adoption wave. Added React Query integration, Zod validation, middleware support, and the create-t3-app scaffold. Community skyrocketed."
v10 Rewrite
2022Complete rewrite with a builder-pattern API. Introduced .input()/.query()/.mutation() chaining, standalone middleware, and improved TypeScript inference."
Production Maturity
2023Major companies adopt tRPC. Blog posts and case studies validate production usage at scale. Integration with Next.js App Router (RSC) begins."
v11 and Beyond
2024–25tRPC v11 stabilizes with improved Next.js App Router support, experimental standalone middleware, .concat() patterns, and refined developer experience."
Core Concepts: The Building Blocks of tRPC
tRPC has a small, composable set of primitives. Understanding these is the foundation for everything else :
| Concept | Description |
|---|---|
| Procedure | A function exposed to the client (query, mutation, or subscription) |
| Query | A read-only procedure for fetching data |
| Mutation | A procedure for writing/modifying data |
| Subscription | A persistent connection for real-time event streams |
| Router | Groups procedures (and sub-routers) under a namespace |
| Context | Data available to every procedure (session, DB, etc.) |
| Middleware | Runs before/after procedures; can modify context or throw errors |
The builder pattern is central to how tRPC works. Procedures are chained like this:
1const publicProcedure = t.procedure; 2 3publicProcedure 4 .input(z.object({ name: z.string() })) // 1. Validate input 5 .use(loggingMiddleware) // 2. Apply middleware 6 .query(async ({ input, ctx }) => { // 3. Execute 7 return { greeting: `Hello, ${input.name}` }; 8 });
This immutability means you can create reusable base procedures — for example, an authenticatedProcedure that automatically checks authorization, then use it across your entire app .
Footnotes
-
tRPC Concepts - Official glossary of tRPC primitives: procedures, routers, context, middleware, validation ↩
-
tRPC Define Procedures - Official docs on building procedures with the builder pattern and reusable base procedures ↩
API Paradigm Comparison
REST vs GraphQL vs tRPC across key dimensions
1// Server: define procedure 2export const appRouter = router({ 3 greeting: publicProcedure 4 .input(z.object({ name: z.string() })) 5 .query(({ input }) => `Hello ${input.name}`), 6}); 7 8// Client: call it with full autocomplete 9const result = await trpc.greeting.query({ name: 'World' }); 10// result: string ← fully inferred!
Building Your First tRPC API — From Scratch
- 1Step 1
Create a new project and install the core dependencies:
1mkdir trpc-app && cd trpc-app 2npm init -y 3npm install @trpc/server @trpc/client zod superjson 4npm install -D typescript @types/node 5npx tsc --initCreate the recommended folder structure:
. ├── server/ │ ├── trpc.ts # tRPC instance & setup │ ├── context.ts # Context factory │ ├── appRouter.ts # Main router + type export │ └── index.ts # HTTP server entry point └── client/ └── index.ts # tRPC client - 2Step 2
In
server/trpc.ts, initialize tRPC with your context shape:1import { initTRPC } from '@trpc/server'; 2import type { Context } from './context'; 3import superjson from 'superjson'; 4 5const t = initTRPC.context<Context>().create({ 6 transformer: superjson, // Enables Date, Map, Set, etc. 7}); 8 9export const router = t.router; 10export const publicProcedure = t.procedure; 11// Export reusable base procedures hereThe
superjsontransformer allows tRPC to transmit types that plain JSON cannot handle .Footnotes
-
tRPC Quickstart - Official quickstart: Your first tRPC API step by step ↩
-
- 3Step 3
In
server/context.ts, define what data every procedure can access:1import type { NextApiRequest, NextApiResponse } from 'next'; 2 3export function createContext({ 4 req, res, 5}: { 6 req: NextApiRequest; 7 res: NextApiResponse; 8}) { 9 return { 10 req, 11 res, 12 // Add: prisma, session, userId, etc. 13 }; 14} 15 16export type Context = ReturnType<typeof createContext>;Context is how you inject dependencies — database connections, authentication state, request/response objects — into every procedure .
Footnotes
-
tRPC Define Procedures - Official docs on building procedures with the builder pattern and reusable base procedures ↩
-
- 4Step 4
In
server/appRouter.ts, define your actual API:1import { z } from 'zod'; 2import { router, publicProcedure } from './trpc'; 3 4let users = [ 5 { id: '1', name: 'Alice' }, 6 { id: '2', name: 'Bob' }, 7]; 8 9export const appRouter = router({ 10 // Query — read data 11 userList: publicProcedure.query(() => users), 12 13 // Query with input validation 14 userById: publicProcedure 15 .input(z.object({ id: z.string() })) 16 .query(({ input }) => users.find(u => u.id === input.id)), 17 18 // Mutation — write data 19 userCreate: publicProcedure 20 .input(z.object({ name: z.string().min(2) })) 21 .mutation(({ input }) => { 22 const user = { id: String(users.length + 1), name: input.name }; 23 users.push(user); 24 return user; 25 }), 26}); 27 28// CRITICAL: Export the TYPE so the client can infer it 29export type AppRouter = typeof appRouter;The
AppRoutertype export is what makes the magic work — the client imports this type-only (no runtime code) to get full autocomplete 2.Footnotes
-
tRPC Quickstart - Official quickstart: Your first tRPC API step by step ↩
-
tRPC Concepts - Official glossary of tRPC primitives: procedures, routers, context, middleware, validation ↩
-
- 5Step 5
In
server/index.ts, attach your router to an HTTP server:1import { createHTTPServer } from '@trpc/server/adapters/standalone'; 2import { appRouter } from './appRouter'; 3import { createContext } from './context'; 4 5const { listen } = createHTTPServer({ 6 router: appRouter, 7 createContext, 8}); 9 10listen(3000, () => { 11 console.log('tRPC server running on http://localhost:3000'); 12}); - 6Step 6
In
client/index.ts, connect the client with full type inference:1import { createTRPCClient, httpBatchLink } from '@trpc/client'; 2import type { AppRouter } from '../server/appRouter'; 3import superjson from 'superjson'; 4 5const client = createTRPCClient<AppRouter>({ 6 links: [ 7 httpBatchLink({ 8 url: 'http://localhost:3000', 9 transformer: superjson, 10 }), 11 ], 12}); 13 14// Full autocomplete + type checking! 15const users = await client.userList.query(); // User[] 16const user = await client.userById.query({ id: '1' }); // User | undefined 17const new = await client.userCreate.mutate({ name: 'Carol' }); // User 18 19// TypeScript error: missing required field 20await client.userCreate.mutate({ name: 42 }); // ❌ Type error!No code generation. No schema file. The types flow from server to client automatically .
Footnotes
-
tRPC Quickstart - Official quickstart: Your first tRPC API step by step ↩
-
Pro Tip: The AppRouter Type Export
The single most important line in your tRPC server is export type AppRouter = typeof appRouter;. This type-only export is what enables end-to-end type safety. The client imports this type (zero runtime cost) and gets full autocomplete, input validation errors, and return type inference — all without a code generation step. If this export is missing or incorrect, the client falls back to unknown types for everything.
Middleware & Protected Procedures
Middleware is tRPC's mechanism for cross-cutting concerns like authentication, logging, rate-limiting, and input sanitization. Middleware runs before (and optionally after) a procedure and can modify the context or throw errors to abort execution .
The recommended pattern is to create reusable base procedures — different "flavors" of t.procedure that carry different middleware chains:
1// server/trpc.ts 2import { initTRPC, TRPCError } from '@trpc/server'; 3import type { Context } from './context'; 4 5const t = initTRPC.context<Context>().create(); 6 7// Public procedure — no auth required 8export const publicProcedure = t.procedure; 9 10// Protected procedure — requires authentication 11const isAuthed = t.middleware(async ({ ctx, next }) => { 12 if (!ctx.session?.user) { 13 throw new TRPCError({ code: 'UNAUTHORIZED' }); 14 } 15 // Extend context with user info for downstream procedures 16 return next({ 17 ctx: { ...ctx, userId: ctx.session.user.id }, 18 }); 19}); 20 21export const protectedProcedure = t.procedure.use(isAuthed);
Now any route built with protectedProcedure automatically requires auth, and the handler gets ctx.userId with a guaranteed TypeScript type:
1export const appRouter = router({ 2 // 👈 Anyone can call 3 publicData: publicProcedure.query(() => 'public'), 4 5 // 🔒 Auth required — ctx.userId is typed as string, not string | undefined 6 mySecret: protectedProcedure.query(({ ctx }) => { 7 return `Hello user ${ctx.userId}`; 8 }), 9});
Footnotes
-
tRPC Middlewares - Official middleware documentation: context narrowing, standalone middleware, .concat() pattern ↩
Warning: Context Narrowing is TypeScript-Only
When middleware extends the context (e.g., adding userId), the narrowing happens at the TypeScript type level, not at runtime. If a malicious client calls a protected endpoint directly, your middleware will throw a TRPCError — but you must always validate at the server. Never assume context fields exist without checking the middleware chain was actually executed.
tRPC with Next.js: The Full-Stack Sweet Spot
tRPC's strongest integration is with Next.js, where the client and server coexist in a single codebase, making type sharing trivial. The recommended setup uses @trpc/react-query which wraps TanStack Query (React Query) to give you caching, optimistic updates, and loading states out of the box .
Recommended file structure for Next.js (App Router, v11):
. ├── src/ │ ├── app/ │ │ ├── layout.tsx # Wrap with TRPCProvider │ │ ├── api/trpc/[trpc]/ │ │ │ └── route.ts # tRPC HTTP handler │ │ └── page.tsx │ ├── server/ │ │ ├── trpc.ts # tRPC instance │ │ ├── context.ts # Context factory │ │ ├── routers/ │ │ │ ├── _app.ts # Root router (merges all) │ │ │ ├── user.ts # User sub-router │ │ │ └── post.ts # Post sub-router │ │ └── index.ts │ ├── trpc/ │ │ ├── client.ts # Client setup (RSC) │ │ └── provider.tsx # Provider for client components │ └── lib/ │ └── utils.ts
Client-side usage with React Query hooks:
1// In a React component: 2function UserList() { 3 // Automatic caching, refetching, loading states 4 const { data: users, isLoading } = trpc.userList.useQuery(); 5 6 const mutation = trpc.userCreate.useMutation({ 7 onSuccess: () => { 8 // Invalidate and refetch after mutation 9 trpcUtils.userList.invalidate(); 10 }, 11 }); 12 13 if (isLoading) return <p>Loading...</p>; 14 15 return ( 16 <ul> 17 {users?.map(u => <li key={u.id}>{u.name}</li>)} 18 </ul> 19 ); 20}
Footnotes
-
tRPC Next.js Pages Router Setup - Official guide for integrating tRPC with Next.js Pages Router ↩
Advanced tRPC Topics
Danger: Don't Over-Couple Your Procedures
A common anti-pattern is shoving all business logic directly into tRPC procedure handlers. Keep your procedures thin — they should validate input, call a service/repository layer, and return results. Business logic and database access should live in separate modules that the procedure calls. This keeps your code testable and allows you to swap tRPC for REST or GraphQL later without rewriting your core logic .
Footnotes
-
Building Production-Ready tRPC APIs - InfoQ - Production migration story: 2.4M requests/day, 99.97% uptime, error formatting, and middleware patterns ↩
Developer Experience: Time to First Working API
Relative effort comparison (lower is better)
tRPC with Next.js — Minimal Setup (Pages Router)
- 1Step 1
1npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@latest zod superjson - 2Step 2
In
pages/api/trpc/[trpc].ts:1import { createNextApiHandler } from '@trpc/server/adapters/next'; 2import { appRouter } from '@/server/routers/_app'; 3import { createContext } from '@/server/context'; 4 5export default createNextApiHandler({ 6 router: appRouter, 7 createContext, 8 onError: ({ error }) => { 9 console.error('tRPC Error:', error); 10 }, 11}); 12 13// Next.js config 14export const config = { api: { bodyParser: false } }; // Allow batching - 3Step 3
In
utils/trpc.ts:1import { createTRPCNext } from '@trpc/next'; 2import type { AppRouter } from '@/server/routers/_app'; 3import { httpBatchLink } from '@trpc/client'; 4import superjson from 'superjson'; 5 6export const trpc = createTRPCNext<AppRouter>({ 7 config: () => ({ 8 links: [ 9 httpBatchLink({ 10 url: '/api/trpc', 11 transformer: superjson, 12 }), 13 ], 14 }), 15 ssr: true, // Enable server-side rendering of tRPC queries 16}); - 4Step 4
In
pages/_app.tsx:1import { trpc } from '@/utils/trpc'; 2 3function MyApp({ Component, pageProps }) { 4 return <Component {...pageProps} />; 5} 6 7export default trpc.withTRPC(MyApp);Now every component in your app can use
trpc.*.useQuery()andtrpc.*.useMutation()hooks with full type safety and autocomplete .Footnotes
-
tRPC Next.js Pages Router Setup - Official guide for integrating tRPC with Next.js Pages Router ↩
-
- 5Step 5
In any React component:
1import { trpc } from '@/utils/trpc'; 2 3function Dashboard() { 4 const { data, isLoading, error } = trpc.userList.useQuery(); 5 const createUser = trpc.userCreate.useMutation(); 6 7 const handleCreate = () => { 8 createUser.mutate({ name: 'New User' }); 9 }; 10 11 if (isLoading) return <div>Loading...</div>; 12 if (error) return <div>Error: {error.message}</div>; 13 14 return ( 15 <div> 16 {data?.map(user => <p key={user.id}>{user.name}</p>)} 17 <button onClick={handleCreate}>Add User</button> 18 </div> 19 ); 20}
Pro Tip: Error Formatting with Zod
When using Zod for input validation, configure a custom error formatter on your tRPC instance to send structured validation errors to the client:
1const t = initTRPC.context<Context>().create({ 2 errorFormatter({ shape, error }) { 3 return { 4 ...shape, 5 data: { 6 ...shape.data, 7 zodError: error.cause instanceof ZodError 8 ? error.cause.flatten() 9 : null, 10 }, 11 }; 12 }, 13});
This lets the client access error.data.zodError.fieldErrors to show per-field validation messages in your form UI .
Footnotes
-
Building Production-Ready tRPC APIs - InfoQ - Production migration story: 2.4M requests/day, 99.97% uptime, error formatting, and middleware patterns ↩
Knowledge Check
What is the primary mechanism that enables tRPC's end-to-end type safety without code generation?
Explore Related Topics
Learn React in 30 Days: A Comprehensive Course
Learn React in 30 Days: From Zero to Production
TCP/IP Networking: The Architecture of the Internet
This course covers the TCP/IP suite’s four‑layer architecture, key protocols, connection setup, addressing, and security considerations.
- Each TCP/IP layer adds its own header via encapsulation, moving data from a process to the physical medium.
- TCP is reliable and connection‑oriented; UDP is fast, connectionless with a small 8‑byte header.
- The 3‑way handshake uses SYN, SYN‑ACK, then ACK to establish a TCP connection.
- IPv4 uses 32‑bit addresses; IPv6 uses 128‑bit, and subnet masks define network vs host bits.
- Ports 0‑1023 are well‑known (e.g., 80 for HTTP); IP spoofing is a security threat.
Thrashing in Operating Systems: Causes, Mechanisms, and Control
Thrashing is a severe performance collapse where the operating system spends most of its time handling page faults and swapping pages because the combined working‑set demand of active processes exceeds available physical memory.
- When and (total demand > frames), paging dominates execution.
- The root cause is memory overcommitment—excessive degree of multiprogramming or processes with too large footprints.
- Symptoms include very high page‑fault rates, intense disk paging activity, and sharply reduced CPU productivity.
- Classic controls are the working‑set model, page‑fault‑frequency monitoring, lowering multiprogramming, using local replacement, and adding RAM.
- Preventive rule: only keep a set of active processes whose working sets can collectively fit in RAM.