@trpc/server
@trpc/server is the server half of tRPC, which gives you end-to-end typesafe APIs in TypeScript without schemas or code generation. You define procedures (queries, mutations, subscriptions) in a router; the client imports only the router's type and gets compile-time checking and autocomplete on inputs, outputs, and errors. There is no codegen step and no runtime schema on the wire, and the server package itself has zero dependencies.
The best developer experience available for TypeScript-only, same-repo APIs; batching, middleware, and typed errors come along free. The moment a non-TypeScript consumer appears its core advantage evaporates, so choose based on who will call the API.
Use it if
- Your client and server are both TypeScript in the same repo or monorepo and you own both ends
- You are tired of hand-written fetch wrappers and API types drifting out of sync with the backend
- You want middleware, auth context, and input validation with types flowing through automatically
- You are on Next.js or another full-stack TS setup where the official adapters (Next, Express, Fastify, Fetch) drop in
- Anyone outside your TypeScript codebase consumes the API (mobile teams, partners, other languages): tRPC has no schema to hand them, so use OpenAPI or GraphQL
- Client and server live in separate repos with separate deploys: sharing the AppRouter type across them is clumsy and version skew bites at runtime
- You are building a public API: RPC-shaped endpoints coupled to procedure names are fine internally and hostile externally
- Your router is huge and your machines are not: TypeScript inference over large routers measurably slows tsserver and editor feedback
Setup reality
It is a family of packages, not one install: @trpc/server plus a validator (usually zod) on the back end, @trpc/client and typically the TanStack Query integration on the front end. First-time wiring of the context function, an adapter, and the shared AppRouter type export takes a real afternoon. The v10 to v11 migration renamed client entry points, so tutorials written before 2024 will steer you into APIs that no longer exist, and editor slowness on big routers is a known, documented cost you design around by splitting routers.
Patterns
Create a router with a typed proceduredefine-router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
greet: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `Hello ${input.name}`),
});
export type AppRouter = typeof appRouter;Export only the type to the client; importing the router value client-side drags server code into your bundle.
Build per-request context from headersrequest-context
import { initTRPC } from '@trpc/server';
export async function createContext({ req }: { req: Request }) {
const user = await getUserFromToken(req.headers.get('authorization'));
return { user };
}
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();Call initTRPC.context<Context>() before .create(); adding context later means retyping every procedure.
Protect procedures with middlewareauth-middleware
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);Returning next() with a narrowed ctx is what makes ctx.user non-nullable in every procedure downstream; skip it and you null-check forever.
Validate a mutation input with zodmutation-input
const appRouter = t.router({
createPost: protectedProcedure
.input(z.object({ title: z.string().min(1), body: z.string() }))
.mutation(async ({ input, ctx }) => {
return db.post.create({ data: { ...input, authorId: ctx.user.id } });
}),
});Invalid input rejects with a BAD_REQUEST error before your handler runs; the input type in the handler is inferred from the schema.
Serve the router from a Fetch-based runtimefetch-adapter
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from './router';
import { createContext } from './context';
const handler = (req: Request) =>
fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext });
export { handler as GET, handler as POST };This one adapter covers Next.js route handlers, Bun, Deno, and edge runtimes; export both GET and POST or batched requests fail.
Mount tRPC inside an Express appexpress-adapter
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
const app = express();
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({ router: appRouter, createContext })
);
app.listen(3000);The express adapter gets req and res in createContext instead of a Fetch Request, so context code is adapter-specific.
Call the API from a plain TypeScript clientvanilla-client
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })],
});
const greeting = await client.greet.query({ name: 'world' });v11 renamed createTRPCProxyClient to createTRPCClient; httpBatchLink combines parallel calls into a single HTTP request.
Throw typed errors and log them centrallyerror-handling
import { TRPCError } from '@trpc/server';
throw new TRPCError({ code: 'NOT_FOUND', message: 'post does not exist' });
// central logging in the adapter:
fetchRequestHandler({
endpoint: '/api/trpc', req, router: appRouter, createContext,
onError({ error, path }) {
console.error(`tRPC error on ${path}:`, error);
},
});Plain thrown Errors surface as INTERNAL_SERVER_ERROR with the message hidden in production; use TRPCError codes for anything client-facing.
Validate what leaves the serveroutput-validation
const publicUser = z.object({ id: z.string(), name: z.string() });
const appRouter = t.router({
me: protectedProcedure
.output(publicUser)
.query(({ ctx }) => ctx.user),
});output() strips or rejects extra fields, which is cheap insurance when handlers return raw database rows with password hashes attached.
Stream events with an async generator subscriptionsse-subscription
const appRouter = t.router({
onTick: t.procedure.subscription(async function* (opts) {
let i = 0;
while (!opts.signal?.aborted) {
yield i++;
await new Promise((r) => setTimeout(r, 1000));
}
}),
});v11 subscriptions run over Server-Sent Events with httpSubscriptionLink on the client, so WebSockets are no longer required for streaming.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @orpc/server | npm | You want tRPC-style typesafety but with OpenAPI output for non-TS consumers built in. |
| @ts-rest/core | npm | You prefer a contract-first REST approach that still shares types between client and server. |
| hono | npm | You want a plain typed web framework with an RPC client mode and fewer layers than the tRPC stack. |