The line between the frontend and the backend has always been a place where types go to die. In a normal REST setup you define the shape of a request and a response on the server, then you write those same shapes again on the client. Nothing keeps the two copies honest. Someone renames a field in the database, the server response changes, and the client keeps trusting the old shape until a runtime error gives it away in production.
Code generation and OpenAPI specs try to close that gap, but they cost you a build step and a pile of config to maintain. tRPC takes a different route: if both sides are TypeScript, they can share the same type directly, so the client and server never drift. No generated code, no schema files, no extra pipeline.
Info
This post targets full-stack TypeScript teams, usually in a monorepo with Next.js or a Node backend. If your API also serves a Swift app or a Python service, read the last section first, because tRPC is probably the wrong tool for that boundary.
What tRPC is and why teams keep reaching for it
tRPC (TypeScript Remote Procedure Call) is a small framework for building type-safe APIs. The idea is simple: if the server and the client both run TypeScript, they should share one source of truth for their data contracts instead of maintaining two.
It is widely used, with tens of thousands of GitHub stars and millions of weekly downloads, and the reason is that it deletes the translation layer most APIs carry. You do not define HTTP endpoints, schema files, and resolvers. You define backend procedures, which are just functions that validate their input and return data. The frontend then imports the type of those procedures, not the runtime code, and gets autocomplete, strict checking, and an instant compiler error the moment the backend changes.
In a monorepo that feedback loop is immediate. Rename a field on the server and the client component turns red before you even save the file. That is the whole pitch: the network boundary stops being a place where you guess.
How the types actually flow
The part that surprises people is that there is no magic and no generated artifact. The server exports a single type built from its router, and the client imports it. The TypeScript compiler does the rest.
A typical monorepo keeps the router definitions in their own package so server-only code never leaks into the client bundle. The client depends on that package for its types, not its runtime.
Directorymy-app/
Directoryapps/
Directoryweb/ (Next.js or React frontend)
- …
Directorypackages/
Directoryapi/
Directorysrc/
- init.ts (tRPC instance + context)
Directoryrouters/
- _app.ts (root router, exports AppRouter type)
- user.ts
- server.ts (HTTP adapter)
How tRPC differs from REST and GraphQL
REST, GraphQL, and tRPC each treat type safety differently, and the differences explain when each one fits.
| Feature | tRPC | GraphQL | REST |
|---|---|---|---|
| Protocol | RPC over HTTP | Query language over HTTP | HTTP verbs and resources |
| Schema format | TypeScript (source of truth) | SDL (.graphql files) | OpenAPI or none |
| Type safety | End-to-end, automatic | Generated via codegen | Generated or manual |
| Client coupling | Tight (shares types) | Loose (schema contract) | Loose (HTTP contract) |
| Multi-client support | TypeScript only | Excellent | Excellent |
REST usually gets its types from a spec. You produce an OpenAPI document from the server and run something like openapi-typescript to generate client types. It works, but the safety lives or dies by that build step, and a skipped or broken generation quietly leaves you with a loosely typed boundary again.
GraphQL leans on a strongly typed schema (SDL) and a codegen step (graphql-codegen) to turn that schema into client types. The safety is real, but so is the boilerplate: schema, resolvers, and queries are all written separately.
tRPC skips the schema and the codegen entirely. The server exports a TypeScript type, the client imports it, and the compiler infers everything. There is no step to keep the two sides in sync because they are literally reading the same type.
Setting up a tRPC server
A server needs three things: a context, an initialized tRPC instance, and an HTTP adapter. tRPC ships official adapters for Express, Fastify, the Fetch API (Next.js App Router, edge runtimes), and the plain Node HTTP server.
- Create the context factory. It runs per request and is where you read the auth token and attach shared resources like a database client.
- Initialize tRPC with that context type, and export the reusable
routerandpublicProcedurebuilders. - Mount the root router on an HTTP adapter for your framework.
import {db} from './db';import {initTRPC, TRPCError} from '@trpc/server';import type {CreateExpressContextOptions} from '@trpc/server/adapters/express';
// Runs on every request. Whatever you return here is the `ctx` in procedures.export function createContext({req}: CreateExpressContextOptions) { const token = req.headers.authorization?.replace('Bearer ', '') ?? null; return {token, db};}
export type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
export const router = t.router;export const publicProcedure = t.procedure;The adapter is the only part that changes between frameworks. Everything above stays identical.
import {createContext} from './init';import {appRouter} from './routers/_app';import {createExpressMiddleware} from '@trpc/server/adapters/express';import express from 'express';
const app = express();
app.use('/trpc', createExpressMiddleware({router: appRouter, createContext}));
app.listen(4000, () => console.log('tRPC on http://localhost:4000/trpc'));import {createContext} from '@repo/api/init';import {appRouter} from '@repo/api/routers/_app';import {fetchRequestHandler} from '@trpc/server/adapters/fetch';
const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext, });
export {handler as GET, handler as POST};Routers, procedures, and input validation with Zod
A procedure is one endpoint. It is either a query (reads) or a mutation (writes), and procedures are grouped into routers. Input validation is not optional here, and tRPC integrates natively with Zod, which is the community default because its inference lines up perfectly with TypeScript.
When you pass a Zod schema to .input(), tRPC uses it twice. At runtime the server parses the request against the schema and throws if the data is malformed. At compile time it infers the type from the same schema and enforces it on the client, so the client cannot even send the wrong shape.
import {router, publicProcedure} from '../init';import {z} from 'zod';
export const userRouter = router({ // Query: fetching data getById: publicProcedure .input(z.object({id: z.string().uuid()})) .query(async ({input, ctx}) => { // `input.id` is a validated string by the time we get here. return ctx.db.user.findUnique(input.id); }),
// Mutation: changing data create: publicProcedure .input(z.object({email: z.string().email(), name: z.string().min(2)})) .mutation(async ({input, ctx}) => { return ctx.db.user.create(input); }),});Individual routers merge into one root router, and that root router is the only thing the frontend needs a type for.
import {router} from '../init';import {userRouter} from './user';
export const appRouter = router({ user: userRouter,});
// The single type the frontend imports.export type AppRouter = typeof appRouter;The request lifecycle
It helps to see what a single call actually does on the way through. The client batches the call, the adapter builds a context, middleware runs, Zod validates, and only then does your resolver see the data.
Integrating with React Query on the frontend
tRPC has a first-class integration with TanStack React Query. Version 11 changed how it works. The old approach wrapped React Query in tRPC-specific hooks like trpc.useQuery. The new integration, in @trpc/tanstack-react-query, is query-native: it hands you the queryOptions and mutationOptions that TanStack Query already understands, so you call the standard useQuery and useMutation yourself. Less to learn, and it plays nicely with the React Compiler.
You set up a proxy once:
import type {AppRouter} from '@repo/api/routers/_app';import {QueryClient} from '@tanstack/react-query';import {createTRPCClient, httpBatchLink} from '@trpc/client';import {createTRPCOptionsProxy} from '@trpc/tanstack-react-query';
export const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({ links: [httpBatchLink({url: 'http://localhost:4000/trpc'})],});
// Strongly-typed factory for query keys, query options, and mutation options.export const trpc = createTRPCOptionsProxy<AppRouter>({ client: trpcClient, queryClient,});Then you use plain TanStack Query hooks in components. The proxy supplies the typed keys, the URL, and the fetching logic.
'use client';import {trpc, queryClient} from '../trpc';import {useQuery, useMutation} from '@tanstack/react-query';
export function UserProfile({userId}: {userId: string}) { const {data, isLoading, error} = useQuery( trpc.user.getById.queryOptions({id: userId}), );
const {mutate, isPending} = useMutation( trpc.user.create.mutationOptions({ onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.user.getById.queryKey(), }), }), );
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error fetching user.</div>;
return ( <div> <h1>{data?.name}</h1> <button disabled={isPending} > {isPending ? 'Saving...' : 'Create User'} </button> </div> );}Authentication and middleware
Middleware intercepts a request before it reaches your resolver, which makes it the right home for logging, timing, and authorization. The pattern for auth is to check the context and then extend it with opts.next(), so downstream procedures receive a guaranteed, non-null user.
import {verifyToken} from './auth';import {t} from './init';import {TRPCError} from '@trpc/server';
const isAuthed = t.middleware(async ({ctx, next}) => { if (!ctx.token) { throw new TRPCError({code: 'UNAUTHORIZED', message: 'Missing token'}); }
const user = await verifyToken(ctx.token); if (!user) throw new TRPCError({code: 'UNAUTHORIZED'});
// Extend the context. From here down, `ctx.user` is guaranteed to exist. return next({ctx: {user}});});
export const protectedProcedure = t.procedure.use(isAuthed);Because the middleware narrows the type, any procedure built on protectedProcedure gets a non-nullable ctx.user with no extra null checks.
import {router, protectedProcedure} from '../init';
export const adminRouter = router({ getDashboardStats: protectedProcedure.query(({ctx}) => { // TypeScript knows `ctx.user` is defined here. console.log(`Stats for admin ${ctx.user.id}`); return {activeUsers: 100, revenue: 5000}; }),});What is new in v11
tRPC v11 (March 2025) pushed past plain request/response, and these are the additions most teams will actually use.
FormData and file uploads. Procedures can now accept non-JSON content, including FormData and binary types like Blob, File, and Uint8Array. That means real file uploads without a separate REST endpoint.
import {router, publicProcedure} from '../init';import {TRPCError} from '@trpc/server';import {z} from 'zod';
export const uploadRouter = router({ avatar: publicProcedure .input(z.instanceof(FormData)) .mutation(async ({input}) => { const file = input.get('file'); if (!(file instanceof File)) { throw new TRPCError({code: 'BAD_REQUEST', message: 'No file'}); } // stream `file` to storage here... return {name: file.name, size: file.size}; }),});Subscriptions over Server-Sent Events. Real-time updates no longer force you onto WebSockets. Subscriptions are written as async generators, so you yield values over time and clean up naturally when the client disconnects.
import {router, publicProcedure} from '../init';import {z} from 'zod';
export const messageRouter = router({ onMessage: publicProcedure .input(z.object({roomId: z.string()})) .subscription(async function* ({input, ctx, signal}) { // `ctx.bus` is any async iterable event source. for await (const msg of ctx.bus.subscribe(input.roomId, {signal})) { yield msg; // pushed to the client over SSE } }),});Streaming queries and mutations. With httpBatchStreamLink, a resolver can return a generator and stream results over plain HTTP, no WebSocket required. Pair it with httpSubscriptionLink for the SSE subscriptions above.
import { createTRPCClient, httpBatchStreamLink, httpSubscriptionLink, splitLink,} from '@trpc/client';
const trpcClient = createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (op) => op.type === 'subscription', true: httpSubscriptionLink({url: '/trpc'}), false: httpBatchStreamLink({url: '/trpc'}), }), ],});Subscriptions also gained output validators in v11, so the values you stream are type-checked the same way inputs are.
When to choose tRPC, GraphQL, or REST
tRPC is not a universal answer. The call comes down to who consumes the API and what language they speak.
Reach for tRPC when you own both ends and they are both TypeScript, usually in a monorepo. Internal dashboards, SaaS products on Next.js or React, and anything where developer speed matters most are where it shines. Dropping schema files and codegen is a real velocity win for a TypeScript-only team.
Stay on REST for public APIs. If your backend has to serve a Swift app, Python microservices, or partner integrations, REST gives you the HTTP semantics, caching, and tooling everyone already supports. Adapters like trpc-openapi can expose REST endpoints from tRPC procedures, but treating REST as an afterthought rarely ends well for heavy public use.
Pick GraphQL for federated microservices or complex clients that need fine-grained control over payloads to avoid over-fetching. Its schema contracts let independent teams work across a big graph and aggregate many systems into one API, which is exactly the problem tRPC does not try to solve.
Frequently Asked Questions
No. It is a better fit for one specific case: a full-stack TypeScript codebase where you control the client and the server. Public APIs and non-TypeScript consumers are still REST or GraphQL territory.
Correct. The server exports type AppRouter = typeof appRouter and the client imports that type. TypeScript infers everything, so there is no schema file and no generation step to run or keep in sync.
tRPC works with Zod, Yup, Valibot, and others, but Zod is standard because its inference maps cleanly onto TypeScript. One schema gives you runtime validation and the compile-time input type at once.
Not strictly, but it is the smoothest setup because the client imports the server’s type directly. You can also publish the API package privately and consume its types from a separate repo, at the cost of a versioning step.
Yes, as of v11. Subscriptions run over Server-Sent Events using async generators, and procedures accept FormData and binary types for uploads. You no longer need a side channel for either.
You throw a TRPCError with a code like UNAUTHORIZED or BAD_REQUEST. tRPC maps it to the right HTTP status and delivers a typed error to the client, where React Query surfaces it as the error on the hook.






