End-to-end typesafe APIs made easy. tRPC lets you build and consume fully typesafe APIs without schemas or code generation — you call functions on the client and get back the exact return type of the server procedure, with autocompletion for inputs, outputs, and errors.
This guide covers everything you need to use tRPC v11 as an application developer: installing the packages, defining a backend router, serving it through an adapter, and consuming it from a vanilla client, React, or Next.js. Every command, option, and code example here is drawn directly from the tRPC source at the version stamped in the footer.
Note
tRPC is a TypeScript-first library. It works at the type level — the client imports only the type of your router (
import type { AppRouter }), never its runtime code, so no server logic leaks to the browser. There is no build step, no codegen, and no schema file.
Table of Contents
- Quick Start
- Installation
- Core Concepts
- Defining Your API (Server)
- Serving Your API (Adapters)
- The Client
- React Integration (TanStack React Query)
- Next.js Integration
- Subscriptions (Real-Time)
- Server-Side Calls
- Inferring Types
- Common Tasks (Recipes)
- Troubleshooting
- FAQ
- Package & Adapter Reference
1. Quick Start
The fastest way to understand tRPC is to build a tiny API and call it. Three procedures, one server, one client.
Important
Requirements: tRPC requires TypeScript >= 5.7.2. You should also enable
"strict": truein yourtsconfig.json— non-strict mode is not officially supported.
Install
npm
npm install @trpc/server @trpc/client
yarn
yarn add @trpc/server @trpc/client
pnpm
pnpm add @trpc/server @trpc/client
bun
bun add @trpc/server @trpc/client
We also use Zod for input validation throughout this guide (optional but recommended):
npm install zod
1. Initialize the backend (server/trpc.ts)
import { initTRPC } from '@trpc/server';
/**
* Initialize tRPC backend — do this only ONCE per backend.
*/
const t = initTRPC.create();
// Export reusable helpers rather than the whole `t` object.
export const router = t.router;
export const publicProcedure = t.procedure;
2. Define the router (server/appRouter.ts)
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
type User = { id: string; name: string };
export const appRouter = router({
userList: publicProcedure.query(async () => {
const users: User[] = [{ id: '1', name: 'Katt' }];
return users;
}),
userById: publicProcedure.input(z.string()).query(async (opts) => {
const { input } = opts;
const user: User = { id: input, name: 'Katt' };
return user;
}),
userCreate: publicProcedure
.input(z.object({ name: z.string() }))
.mutation(async (opts) => {
const { input } = opts;
const user: User = { id: '1', ...input };
return user;
}),
});
// Export ONLY the type of the router — never the value.
export type AppRouter = typeof appRouter;
3. Serve it (server/index.ts)
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './appRouter';
const server = createHTTPServer({
router: appRouter,
});
server.listen(3000);
Server is now listening on http://localhost:3000
4. Call it from the client (client/index.ts)
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/appRouter';
// 👆 type-only import — stripped at build time
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000' })],
});
// Fully inferred types and autocompletion:
const user = await trpc.userById.query('1');
// user: { id: string; name: string }
const created = await trpc.userCreate.mutate({ name: 'Katt' });
// created: { id: string; name: string }
That's it. The client knows every procedure, its input type, and its output type — purely from the imported AppRouter type. Change the server, and the client's types update instantly with no regeneration.
Tip
Want a full-stack starter instead of building from scratch? Scaffold one of the official examples:
npx create-next-app --example https://github.com/trpc/trpc --example-path examples/next-prisma-starter trpc-prisma-starter
2. Installation
tRPC is split into several packages so you install only what you need. Match each package to the part of your codebase where it runs.
| Package | Install where | Purpose |
|---|---|---|
@trpc/server |
Backend (and shared types) | Build routers, procedures, adapters. Also provides types the client needs. |
@trpc/client |
Frontend / any consumer | The vanilla typesafe client + links. |
@trpc/tanstack-react-query |
React frontend | Recommended React integration over TanStack Query. |
@trpc/react-query |
React frontend | Classic React hooks integration (createTRPCReact). |
@trpc/next |
Next.js (Pages Router) | createTRPCNext + SSR helpers. |
@tanstack/react-query |
React frontend | Peer dependency for both React integrations. |
Note
@trpc/serveris a zero-dependency package and ships a tiny client-side footprint. Its only peer dependency istypescript(>=5.7.2). You generally install@trpc/serveralongside@trpc/clienteven on the frontend, because the client imports shared types from it.
Typical install matrices
Vanilla client
npm install @trpc/server @trpc/client
React (recommended)
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query
Next.js Pages Router (classic)
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query
Important
Keep all
@trpc/*packages on the same version. Mixing versions across@trpc/server,@trpc/client, and the React integrations can cause subtle type mismatches.
3. Core Concepts
tRPC stands for TypeScript Remote Procedure Call. Instead of designing URLs and HTTP verbs, you call functions.
// HTTP / REST mindset
const res = await fetch('/api/users/1');
const user = await res.json(); // type: any
// tRPC mindset
const user = await trpc.users.getById.query({ id: 1 }); // type: User
Tip
Don't think about HTTP implementation details while writing application code. Verbs like GET/POST carry meaning in REST, but in RPC they become part of your function names (
getUser,createUser). tRPC handles the wire format for you.
Key vocabulary you'll meet throughout this guide:
| Term | Meaning |
|---|---|
| Procedure | A single API endpoint — a query, mutation, or subscription. |
| Query | Reads data (maps to HTTP GET). Should not cause side effects. |
| Mutation | Writes data — create/update/delete (maps to HTTP POST). |
| Subscription | A long-lived real-time stream of events (SSE or WebSockets). |
| Router | A collection of procedures (and nested routers). |
appRouter |
Your single root router. Its type is exported as AppRouter. |
| Context | Per-request data (auth, db handle) available to all procedures. |
| Middleware | Logic that wraps procedure execution (auth checks, logging, timing). |
| Adapter | Glue between tRPC and a host (Node HTTP, Express, Fastify, Next.js, Fetch/edge). |
| Link | A composable unit on the client that controls how operations flow to the server. |
| Transformer | Serializer that lets complex types (Date, Map, Set) survive the wire. |
4. Defining Your API (Server)
This is the heart of tRPC. You define a typesafe router on the server; its type flows to every consumer.
4.1 Initialize tRPC
Call initTRPC exactly once per backend. Multiple instances cause issues.
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
export const mergeRouters = t.mergeRouters;
export const createCallerFactory = t.createCallerFactory;
You can customize the instance with method chaining before .create():
import { initTRPC } from '@trpc/server';
type Context = { userId: string };
type Meta = { description: string };
const t = initTRPC.context<Context>().meta<Meta>().create({
/* runtime options — see below */
});
Runtime configuration options for .create()
| Option | Type | Default | Description |
|---|---|---|---|
transformer |
DataTransformerOptions |
identity | Data transformer (e.g. superjson). Must match on client. |
errorFormatter |
function | default formatter | Customize the error shape sent to clients. |
isDev |
boolean |
process.env.NODE_ENV !== 'production' |
When true, includes stack traces in error responses. |
isServer |
boolean |
typeof window === 'undefined' || 'Deno' in window || NODE_ENV === 'test' |
Whether this is a server environment. |
allowOutsideOfServer |
boolean |
false |
Allow @trpc/server to run in non-server environments (mainly for testing). |
defaultMeta |
Meta |
— | Default meta applied to procedures. |
sse |
object | — | Server-sent-events tuning (ping, maxDurationMs, client reconnect). See §9. |
jsonl |
object | — | JSON-lines streaming tuning (pingMs) for httpBatchStreamLink. |
Warning
If you call
initTRPCoutside a server environment withoutallowOutsideOfServer: true, tRPC throws:You're trying to use @trpc/server in a non-server environment.UseallowOutsideOfServer: trueonly in tests.
Tip
Export the individual helpers (
router,publicProcedure, …) — not thetobject itself. This keeps a clean, consistent vocabulary across your codebase and makes the base-procedure pattern natural.
4.2 Procedures: queries, mutations, subscriptions
A procedure is a function exposed to the client. It is one of three types:
query— fetch data, no side effects (HTTPGET)mutation— create/update/delete (HTTPPOST)subscription— real-time event stream (see §9)
const appRouter = router({
// Query — read
hello: publicProcedure.query(() => {
return { message: 'hello world' };
}),
// Mutation — write
goodbye: publicProcedure.mutation(async (opts) => {
await opts.ctx.signGuestBook();
return { message: 'goodbye!' };
}),
});
The resolver receives a single opts object containing input, ctx, type, path, and (for subscriptions) signal.
Note
Procedures use an immutable builder pattern. Each method (
.input(),.use(),.output()) returns a new builder, which is why you can create reusable base procedures.
4.3 Input & output validators
Validators check and parse the input/output of a procedure and infer its types.
Input validation with .input()
import { z } from 'zod';
export const appRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string() }))
.query((opts) => {
// opts.input.name is typed as string
return { greeting: `Hello ${opts.input.name}` };
}),
});
Input merging (stacking .input())
.input() can be chained to build complex types — especially useful when a common input is consumed by a middleware. Only object types can be merged; later keys win on conflict.
const baseProcedure = t.procedure
.input(z.object({ townName: z.string() }))
.use((opts) => {
console.log(`Request from: ${opts.input.townName}`);
return opts.next();
});
export const appRouter = router({
hello: baseProcedure
.input(z.object({ name: z.string() }))
.query((opts) => {
// opts.input has BOTH townName and name
return { greeting: `Hello ${opts.input.name} from ${opts.input.townName}` };
}),
});
Output validation with .output()
export const appRouter = router({
hello: publicProcedure
.output(z.object({ greeting: z.string() }))
.query(() => {
return { greeting: 'hello world' };
}),
});
Important
If output validation fails, the server responds with
INTERNAL_SERVER_ERROR(notBAD_REQUEST). Output validation is for catching server-side mistakes and trimming over-returned data — input is already type-safe from inference, so output validators are optional.
Supported validators
tRPC works out of the box with any library conforming to Standard Schema, plus first-class support for many popular libraries:
| Validator | Usage example |
|---|---|
| Zod (recommended) | .input(z.object({ name: z.string() })) |
| Yup | .input(yup.object({ name: yup.string().required() })) |
| Valibot | .input(v.object({ name: v.string() })) |
| Superstruct | .input(object({ name: string() })) |
| ArkType | .input(type({ name: 'string' })) |
| effect (Schema) | .input(Schema.standardSchemaV1(Schema.Struct({ name: Schema.String }))) |
| Typia | .input(typia.createAssert<T>()) |
| TypeBox | .input(wrap(Type.Object({ name: Type.String() }))) |
| scale-ts | .input($.object($.field('name', $.str))) |
The simplest validator: a function
You don't need a library at all — any function that returns a typed value or throws is a valid parser:
export const appRouter = router({
hello: publicProcedure
.input((val: unknown): string => {
if (typeof val === 'string') return val;
throw new Error(`Invalid input: ${typeof val}`);
})
.query((opts) => `hello ${opts.input}`),
});
4.4 Routers and sub-routers
A router groups procedures. You can nest routers, and inline objects are treated as sub-routers:
const appRouter = router({
// Using the router() helper
nested1: router({
proc: publicProcedure.query(() => '...'),
}),
// Inline sub-router (equivalent)
nested2: {
proc: publicProcedure.query(() => '...'),
},
});
On the client these become trpc.nested1.proc.query() and trpc.nested2.proc.query().
Lazy-loading routers
Reduce cold-start time by dynamically importing sub-routers with lazy:
import { lazy } from '@trpc/server';
import { router } from './trpc';
export const appRouter = router({
// Short-hand when the module has exactly 1 router exported
greeting: lazy(() => import('./greeting.js')),
// When the module exports more than 1 router
user: lazy(() => import('./user.js').then((m) => m.userRouter)),
});
There's no difference in how you consume a lazy-loaded router on the client.
4.5 Merging routers
As your API grows, split it into files and merge them. Two strategies:
Namespaced (child routers):
import { router } from './trpc';
import { userRouter } from './user';
import { postRouter } from './post';
const appRouter = router({
user: userRouter, // trpc.user.list.query()
post: postRouter, // trpc.post.create.mutate()
});
export type AppRouter = typeof appRouter;
Flat (with mergeRouters):
import { mergeRouters } from './trpc';
import { userRouter } from './user';
import { postRouter } from './post';
// All procedures live in a single flat namespace
const appRouter = mergeRouters(userRouter, postRouter);
export type AppRouter = typeof appRouter;
4.6 Context
Context holds per-request data — auth info, a database connection — available in every procedure and middleware. Setup is two steps: declare the type at init, create the value per request.
import { initTRPC } from '@trpc/server';
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
export const createContext = async (opts: CreateHTTPContextOptions) => {
const token = opts.req.headers['authorization'];
return { token };
};
export type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
Pass createContext to your adapter:
import { createHTTPHandler } from '@trpc/server/adapters/standalone';
const handler = createHTTPHandler({
router: appRouter,
createContext,
});
Note
createContext()is called once per request, so all procedures within a single batched request share the same context value.
Inner & outer context
A common pattern splits context into inner (request-independent, e.g. the db client — always available, great for tests and server-side calls) and outer (request-dependent, e.g. the session — only available over HTTP).
export async function createContextInner(opts?: { session: Session | null }) {
return { db, session: opts?.session };
}
export async function createContext(opts: CreateHTTPContextOptions) {
const session = getSessionFromCookie(opts.req);
const inner = await createContextInner({ session });
return { ...inner, req: opts.req, res: opts.res };
}
// Infer Context from the INNER function — only it is always available.
export type Context = Awaited<ReturnType<typeof createContextInner>>;
Tip
Avoid putting large generated clients (like a full Prisma client) directly on the context type if type-checking gets slow — import the client at call sites instead. Inner/outer context keeps this flexible.
4.7 Middleware & reusable base procedures
Add middleware with .use(). Middleware wraps procedure execution and must call opts.next() and return its result.
export const loggedProcedure = publicProcedure.use(async (opts) => {
const start = Date.now();
const result = await opts.next();
const durationMs = Date.now() - start;
const meta = { path: opts.path, type: opts.type, durationMs };
result.ok
? console.log('OK request timing:', meta)
: console.error('Non-OK request timing', meta);
return result;
});
Reusable "base procedures" — the key reuse pattern
The recommended pattern: rename t.procedure to publicProcedure, then build specialized procedures from it. This is how you share auth/behavior across many procedures.
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure;
// Asserts the user is logged in
export const authedProcedure = t.procedure.use(async function isAuthed(opts) {
if (!opts.ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
user: opts.ctx.user, // now non-nullable for downstream
},
});
});
// Build further on authedProcedure
export const organizationProcedure = authedProcedure
.input(z.object({ organizationId: z.string() }))
.use(function isMemberOfOrganization(opts) {
const membership = opts.ctx.user.memberships.find(
(m) => m.Organization.id === opts.input.organizationId,
);
if (!membership) throw new TRPCError({ code: 'FORBIDDEN' });
return opts.next({ ctx: { Organization: membership.Organization } });
});
Context extension
Returning ctx from opts.next({ ctx }) adds/overrides keys typesafely for all downstream middleware and the resolver:
const protectedProcedure = publicProcedure.use(async function isAuthed(opts) {
if (!opts.ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return opts.next({ ctx: { user: opts.ctx.user } }); // user now non-null downstream
});
Composing middleware: .concat() and .pipe()
.concat()— attach an independently-defined partial procedure (a plugin) to any compatible instance. Primarily for building plugins/libraries..unstable_pipe()— extend one middleware with another in a typesafe manner. Order matters; contexts must overlap.
const fooMiddleware = t.middleware((opts) =>
opts.next({ ctx: { foo: 'foo' as const } }),
);
const barMiddleware = fooMiddleware.unstable_pipe((opts) =>
opts.next({ ctx: { bar: 'bar' as const } }),
);
const barProcedure = publicProcedure.use(barMiddleware);
Note
experimental_standaloneMiddleware()still exists but is deprecated in favor of.concat(). Use.concat()for new code.
4.8 Authorization
createContext runs per request — derive the calling user there, then enforce access either in the resolver or via middleware.
In createContext (derive the user):
export async function createContext({ req }: CreateHTTPContextOptions) {
async function getUserFromHeader() {
if (req.headers.authorization) {
return await decodeAndVerifyJwtToken(req.headers.authorization.split(' ')[1]);
}
return null;
}
return { user: await getUserFromHeader() };
}
Option 1 — authorize in the resolver:
secret: t.procedure.query((opts) => {
if (!opts.ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return { secret: 'sauce' };
}),
Option 2 — authorize via middleware (reusable):
export const protectedProcedure = t.procedure.use(async function isAuthed(opts) {
if (!opts.ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return opts.next({ ctx: { user: opts.ctx.user } });
});
4.9 Metadata
Attach arbitrary per-procedure meta (available in every middleware) — useful for declarative auth or for OpenAPI generation.
interface Meta {
authRequired: boolean;
}
const t = initTRPC.context<Context>().meta<Meta>().create({
defaultMeta: { authRequired: false },
});
const authedProcedure = t.procedure.use(async (opts) => {
if (opts.meta?.authRequired && !opts.ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next();
});
export const appRouter = t.router({
hello: authedProcedure.meta({ authRequired: false }).query(() => 'hi'),
protectedHello: authedProcedure.meta({ authRequired: true }).query(() => 'secret'),
});
Tip
When you chain
.meta()on top of a base procedure, the values are shallow-merged. UsedefaultMetain.create()to set baseline values.
4.10 Error handling
Throw a TRPCError to represent a failure with a specific code:
import { TRPCError } from '@trpc/server';
const appRouter = t.router({
hello: t.procedure.query(() => {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred, please try again later.',
cause: theError, // optional — retains the original stack
});
}),
});
Error codes
tRPC defines these codes (each maps to an HTTP status):
| Code | HTTP | Meaning |
|---|---|---|
PARSE_ERROR |
400 | Invalid JSON / parse failure |
BAD_REQUEST |
400 | Client error (e.g. input validation failure) |
UNAUTHORIZED |
401 | Missing/invalid credentials |
PAYMENT_REQUIRED |
402 | Payment needed |
FORBIDDEN |
403 | Not allowed |
NOT_FOUND |
404 | Resource not found |
METHOD_NOT_SUPPORTED |
405 | Method not supported |
TIMEOUT |
408 | Server closing unused connection |
CONFLICT |
409 | Conflicts with current state |
PRECONDITION_FAILED |
412 | Access denied (precondition) |
PAYLOAD_TOO_LARGE |
413 | Request too large |
UNSUPPORTED_MEDIA_TYPE |
415 | Unsupported payload format |
UNPROCESSABLE_CONTENT |
422 | Understood but unprocessable |
PRECONDITION_REQUIRED |
428 | Missing precondition header |
TOO_MANY_REQUESTS |
429 | Rate limit exceeded |
CLIENT_CLOSED_REQUEST |
499 | Client closed connection |
INTERNAL_SERVER_ERROR |
500 | Unspecified server error |
NOT_IMPLEMENTED |
501 | Not implemented |
BAD_GATEWAY |
502 | Bad upstream response |
SERVICE_UNAVAILABLE |
503 | Not ready |
GATEWAY_TIMEOUT |
504 | Upstream timeout |
Extract the HTTP status from an error:
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
if (error instanceof TRPCError) {
const httpCode = getHTTPStatusCodeFromError(error); // e.g. 400
}
Handling errors with onError
Every error passes through the adapter's onError callback before reaching the client — ideal for logging / bug reporting:
const server = createHTTPServer({
router: appRouter,
onError(opts) {
const { error, type, path, input, ctx, req } = opts;
console.error('Error:', error);
if (error.code === 'INTERNAL_SERVER_ERROR') {
// report to your error tracker
}
},
});
Note
Stack traces are included in the error response only when
isDevistrue(defaults toNODE_ENV !== 'production'). For deterministic behavior across runtimes, setisDevexplicitly ininitTRPC.create({ isDev: false }).
4.11 Error formatting
Reshape the error object sent to clients — the new shape is inferred all the way to the client. The classic example surfaces Zod validation details:
import { initTRPC } from '@trpc/server';
import { ZodError } from 'zod';
export const t = initTRPC.create({
errorFormatter(opts) {
const { shape, error } = opts;
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
On the client, error.data.zodError is now typed and available:
if (mutation.error?.data?.zodError) {
return <pre>{JSON.stringify(mutation.error.data.zodError, null, 2)}</pre>;
}
4.12 Data transformers
By default tRPC sends JSON, so Date/Map/Set arrive as strings/objects. A transformer fixes that. Add it to both the server (initTRPC) and the client (every link).
superjson (human-readable, transparent Date/Map/Set):
// server
import superjson from 'superjson';
export const t = initTRPC.create({ transformer: superjson });
// client
import { createTRPCClient, httpLink } from '@trpc/client';
import superjson from 'superjson';
export const client = createTRPCClient<AppRouter>({
links: [httpLink({ url: 'http://localhost:3000', transformer: superjson })],
});
devalue (more compact, less readable — use parse/stringify for XSS mitigation):
import { parse, stringify } from 'devalue';
export const transformer = {
deserialize: (object: any) => parse(object),
serialize: (object: any) => stringify(object),
};
Important
The transformer must be identical on server and client, and you must add it to every HTTP/WS link. TypeScript will guide you to the spots that need it once you set it on
initTRPC. You can also supply different transformers for upload vs. download via theCombinedDataTransformershape ({ input, output }).
5. Serving Your API (Adapters)
tRPC is not a server by itself — an adapter hosts your router. All adapters support createContext and onError. Pick by deployment target.
| Adapter | Import | Use case |
|---|---|---|
| Standalone | @trpc/server/adapters/standalone |
Node HTTP server. Simplest; great for local dev and serverful prod. |
| Express | @trpc/server/adapters/express |
Add tRPC to an existing Express app. |
| Fastify | @trpc/server/adapters/fastify |
Add tRPC to a Fastify app. |
| Fetch | @trpc/server/adapters/fetch |
Edge runtimes & frameworks using Web Request/Response (Cloudflare Workers, Deno, Vercel Edge, Astro, Remix, SolidStart). |
| AWS Lambda | @trpc/server/adapters/aws-lambda |
Serverless on Lambda (with optional response streaming). |
| Next.js | @trpc/server/adapters/next |
Next.js API routes. |
| Next.js App Dir | @trpc/server/adapters/next-app-dir |
Next.js App Router. |
| WebSockets | @trpc/server/adapters/ws |
WebSocket server for subscriptions. |
Tip
For local development or serverful infrastructure, start with the Standalone adapter. Swapping later is trivial. It's also common to keep two entry points: Standalone for local dev, and a different adapter when deployed.
Standalone (Node HTTP)
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './appRouter';
createHTTPServer({
router: appRouter,
createContext() {
return {};
},
// basePath: '/trpc/', // optional, defaults to '/'
}).listen(2022);
createHTTPServer returns a Node http.Server. For full control, use createHTTPHandler and mount it into your own server. There's also createHTTP2Handler for HTTP/2.
CORS with Standalone — it does not handle CORS/OPTIONS by default. Use the cors package via the middleware option:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import cors from 'cors';
createHTTPServer({
middleware: cors(),
router: appRouter,
createContext() {
return {};
},
}).listen(3333);
Express
import * as trpcExpress from '@trpc/server/adapters/express';
import express from 'express';
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({});
type Context = Awaited<ReturnType<typeof createContext>>;
const app = express();
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({ router: appRouter, createContext }),
);
app.listen(4000);
Your endpoints are then available over HTTP:
| Procedure | HTTP |
|---|---|
getUser (query) |
GET /trpc/getUser?input=<URI-encoded JSON> |
createUser (mutation) |
POST /trpc/createUser with JSON body |
Fetch / Edge runtimes
The Fetch adapter takes a native Request and returns a Response, so it runs anywhere WinterCG-compatible. The tRPC code is identical across runtimes; only how you wire the handler differs.
// Cloudflare Worker
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createContext } from './context';
import { appRouter } from './router';
export default {
async fetch(request: Request): Promise<Response> {
return fetchRequestHandler({
endpoint: '/trpc',
req: request,
router: appRouter,
createContext,
});
},
};
The same fetchRequestHandler works for Astro, Remix, SolidStart, Deno Deploy, Vercel Edge, and Next.js Edge — see the adapter reference.
Tip
Because the Fetch adapter only needs
Request,Response,fetch,Headers, andURL, you can even run a tRPC server in the browser.
6. The Client
6.1 Vanilla client
Create a client with createTRPCClient, giving it a links array with at least one terminating link pointing at your API.
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
async headers() {
return { authorization: getAuthCookie() };
},
}),
],
});
Call procedures via a typed proxy. Note the client-side method names: .query(), .mutate(), .subscribe().
const bilbo = await client.getUser.query('id_bilbo');
const frodo = await client.createUser.mutate({ name: 'Frodo' });
Note
createTRPCProxyClientis a deprecated alias forcreateTRPCClient(removed in v12). UsecreateTRPCClient.
6.2 Links
Links control the flow of data between client and server, similar to Apollo links. They run in order on the way out and in reverse on the way back. The last link must be a terminating link that actually sends the request.
export const trpc = createTRPCClient<AppRouter>({
links: [
loggerLink(), // non-terminating
httpBatchLink({ url: 'http://localhost:3000' }), // terminating
],
});
Available links
| Link | Terminating? | Purpose |
|---|---|---|
httpBatchLink |
✅ | Recommended. Batches multiple calls into one HTTP request. |
httpBatchStreamLink |
✅ | Like httpBatchLink but streams responses as they're ready (supports async generators). |
httpLink |
✅ | One HTTP request per call (no batching). Supports GET & POST. |
httpSubscriptionLink |
✅ | Subscriptions over Server-sent Events (SSE). |
wsLink |
✅ | Queries/mutations/subscriptions over WebSockets. |
localLink |
✅ | Terminating link for in-process calls. |
loggerLink |
❌ | Pretty per-operation console logging. |
splitLink |
❌ | Branch the chain based on a condition. |
retryLink |
❌ | Retry failed operations with backoff. |
httpBatchLink (recommended terminating link)
Batches all calls made in the same tick into a single HTTP request:
const somePosts = await Promise.all([
trpc.post.byId.query(1),
trpc.post.byId.query(2),
trpc.post.byId.query(3),
]); // → exactly ONE HTTP request
Key options (extends HTTPLinkOptions):
| Option | Default | Description |
|---|---|---|
url |
— | API URL (string or URL). |
transformer |
— | Data transformer (must match server). |
headers |
— | Object or callback returning headers per request. |
fetch |
— | Custom fetch ponyfill. |
maxURLLength |
Infinity |
Split into multiple requests if the batch URL exceeds this length. |
maxItems |
Infinity |
Max operations per batch request. |
methodOverride |
— | 'POST' to send all calls as POST. |
Warning
Long GET batch URLs can hit
413,414, or404. SetmaxURLLength(e.g.2083) or usemethodOverride: 'POST'. To cap batch size end-to-end, setmaxBatchSizeon the server adapter and a matchingmaxItemson the client.
httpLink (no batching)
const client = createTRPCClient<AppRouter>({
links: [httpLink({ url: 'http://localhost:3000' })],
});
To disable batching server-side too, pass allowBatching: false to the adapter.
httpBatchStreamLink (streaming)
Same options as httpBatchLink plus streamHeader ('trpc-accept' default, or 'accept' to avoid CORS preflight). Streams responses as soon as each is ready and supports async-generator procedures:
const iterable = await trpc.examples.iterable.query();
for await (const value of iterable) {
console.log('Iterable:', value);
}
Caution
httpBatchStreamLinkcannot set response headers/cookies once the stream begins — usehttpBatchLinkif your procedures set headers. On AWS Lambda it requires response streaming or it falls back tohttpBatchLink. On Cloudflare Workers, enable thestreams_enable_constructorsflag.
splitLink (conditional routing)
Branch the chain — both true and false branches are required. The classic use is sending subscriptions over SSE/WS and queries/mutations over HTTP:
import { splitLink, httpBatchLink, httpSubscriptionLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({ url: '/api/trpc' }),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});
You can also use it to disable batching for specific calls via operation context:
splitLink({
condition: (op) => Boolean(op.context.skipBatch),
true: httpLink({ url }),
false: httpBatchLink({ url }),
});
// call with: trpc.posts.query(undefined, { context: { skipBatch: true } })
loggerLink
loggerLink({
enabled: (opts) =>
(process.env.NODE_ENV === 'development' && typeof window !== 'undefined') ||
(opts.direction === 'down' && opts.result instanceof Error),
});
Options: logger, enabled, console, colorMode ('ansi' | 'css' | 'none'), withContext.
retryLink
retryLink({
retry(opts) {
if (opts.error.data && opts.error.data.code !== 'INTERNAL_SERVER_ERROR') return false;
if (opts.op.type !== 'query') return false;
return opts.attempts <= 3;
},
retryDelayMs: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Tip
If you use
@trpc/react-queryor@trpc/tanstack-react-query, you usually don't needretryLink— retry is built into TanStack Query'suseQuery/useMutation.
6.3 Headers & authentication
The headers option works on any HTTP link. As a function it's called per request — ideal for auth tokens:
let token: string;
export function setToken(newToken: string) {
token = newToken;
}
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000',
headers() {
return { Authorization: token };
},
}),
],
});
// After login:
const result = await trpc.auth.login.mutate({ username, password });
setToken(result.accessToken);
6.4 CORS & cookies
To send cookies cross-origin, enable CORS on the server and pass { credentials: 'include' } to fetch on the client:
httpBatchLink({
url: 'YOUR_SERVER_URL',
fetch(url, options) {
return fetch(url, { ...options, credentials: 'include' });
},
});
Important
You must also enable CORS on the server (via your adapter or the HTTP server fronting it). See the Standalone CORS example.
7. React Integration (TanStack React Query)
@trpc/tanstack-react-query is the recommended React integration. It's thin and TanStack-native: it gives you type-safe factories for queryOptions, mutationOptions, queryKey, etc., which you pass to standard TanStack Query hooks.
Setup
Install: @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query.
1. Create the context (utils/trpc.ts):
import { createTRPCContext } from '@trpc/tanstack-react-query';
import type { AppRouter } from '../server/router';
export const { TRPCProvider, useTRPC, useTRPCClient } =
createTRPCContext<AppRouter>();
2. Wrap your app (App.tsx):
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { TRPCProvider } from '../utils/trpc';
function makeQueryClient() {
return new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
});
}
export function App() {
const [queryClient] = useState(makeQueryClient);
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:2022' })],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{/* your app */}
</TRPCProvider>
</QueryClientProvider>
);
}
Tip
For SSR (e.g. Next.js), create a new
QueryClientper request on the server so users don't share a cache; reuse one in the browser. If you already use React Query, reuse your existingQueryClientandQueryClientProvider.
SPA without React context — create singletons with createTRPCOptionsProxy:
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:2022' })],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});
Fetching data
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTRPC } from '../utils/trpc';
export default function UserList() {
const trpc = useTRPC();
const userQuery = useQuery(trpc.getUser.queryOptions({ id: 'id_bilbo' }));
const userCreator = useMutation(trpc.createUser.mutationOptions());
return (
<div>
<p>{userQuery.data?.name}</p>
<button onClick={() => userCreator.mutate({ name: 'Frodo' })}>
Create Frodo
</button>
</div>
);
}
The factory methods
At the end of any procedure proxy, these type-safe factories are available:
| Method | Available on | Returns |
|---|---|---|
queryOptions(input, opts?) |
queries | Options for useQuery / useSuspenseQuery / fetchQuery / prefetchQuery. |
infiniteQueryOptions(input, opts) |
queries with a cursor input |
Options for useInfiniteQuery. |
queryKey(input?) |
queries | Type-safe query key for cache operations. |
infiniteQueryKey(input?) |
cursor queries | Query key for infinite queries. |
queryFilter(input?, filter?) |
queries | Type-safe query filter. |
infiniteQueryFilter(...) |
cursor queries | Query filter for infinite queries. |
mutationOptions(opts?) |
mutations | Options for useMutation. |
mutationKey() |
mutations | Type-safe mutation key. |
subscriptionOptions(input, opts) |
subscriptions | Options for useSubscription (from @trpc/tanstack-react-query). |
pathKey() / pathFilter() |
any router/sub-path | Partial key/filter to match all queries under a path (fuzzy matching). |
Disabling a query type-safely with skipToken:
import { skipToken, useQuery } from '@tanstack/react-query';
const query = useQuery(
trpc.user.details.queryOptions(
user?.id ? { userId: user.id } : skipToken,
{ staleTime: 1000 },
),
);
Invalidating by path (fuzzy matching):
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: trpc.path.to.query.queryKey() });
// or invalidate a whole router:
queryClient.invalidateQueries(trpc.path.pathFilter());
Query Key Prefixing — when using multiple tRPC providers (e.g. different backends), enable keyPrefix to prevent same-path collisions:
const billing = createTRPCContext<BillingRouter, { keyPrefix: true }>();
// <BillingProvider keyPrefix="billing" ...>
Subscriptions in React
TanStack has no subscription hook, so tRPC provides useSubscription:
import { useSubscription } from '@trpc/tanstack-react-query';
const subscription = useSubscription(
trpc.path.to.subscription.subscriptionOptions(
{ /* input */ },
{
enabled: true,
onData: (data) => { /* handle */ },
onError: (error) => { /* handle */ },
onConnectionStateChange: (state) => { /* handle */ },
},
),
);
// subscription.status: 'idle' | 'connecting' | 'pending' | 'error'
// subscription.data, subscription.error, subscription.reset()
Requires httpSubscriptionLink or wsLink configured. See §9.
Note
The classic integration (
@trpc/react-query,createTRPCReact) is still supported and provides hooks liketrpc.userById.useQuery(),useMutation(),useInfiniteQuery(),useSuspenseQuery(),useSubscription(), anduseUtils(). The TanStack-native integration above is recommended for new projects.
8. Next.js Integration
There are two paths for Next.js:
- Fetch adapter + TanStack integration — works for both App Router and Pages Router and is the modern recommendation. Mount
fetchRequestHandlerin a route handler and use@trpc/tanstack-react-queryon the client. @trpc/next(createTRPCNext) — the classic Pages Router integration with built-in SSR support.
Classic Pages Router (createTRPCNext)
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server';
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
links: [httpBatchLink({ url: '/api/trpc' })],
};
},
ssr: false,
});
Wrap your _app.tsx with trpc.withTRPC(MyApp). For SSR, set ssr: true and provide ssrPrepass (import { ssrPrepass } from '@trpc/next/ssrPrepass'). The API route uses the Next adapter:
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/router';
export default createNextApiHandler({
router: appRouter,
// allowBatching: false, // optional
// maxBatchSize: 10, // optional
});
Tip
For App Router + React Server Components, use the Fetch adapter for the route handler and
@trpc/tanstack-react-query(withcreateTRPCOptionsProxy/createHydrationHelpersfor prefetching in server components).
9. Subscriptions (Real-Time)
Subscriptions push a real-time event stream from server to client. Define a subscription as an async generator that yields events. Use either SSE (httpSubscriptionLink) or WebSockets (wsLink).
Tip
If unsure, use SSE — it's easier to set up and needs no WebSocket server.
Defining a subscription
import EventEmitter, { on } from 'node:events';
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const ee = new EventEmitter();
export const appRouter = t.router({
onPostAdd: t.procedure.subscription(async function* (opts) {
for await (const [data] of on(ee, 'add', { signal: opts.signal })) {
yield data as Post;
}
}),
});
Note
opts.signalis anAbortSignalthat fires when the client disconnects. Pass it toon(...)/ your data source so listeners are cleaned up automatically. For cleanup of side effects, usetry { ... } finally { ... }— tRPC calls the generator's.return()when the subscription stops.
Automatic reconnection with tracked()
Wrap events with tracked(id, data) so the client can reconnect and resume from the last received id (sent back as lastEventId in your input):
import { tracked } from '@trpc/server';
import { z } from 'zod';
export const subRouter = router({
onPostAdd: publicProcedure
.input(z.object({ lastEventId: z.string().nullish() }).optional())
.subscription(async function* (opts) {
if (opts.input?.lastEventId) {
// fetch & yield events since lastEventId
}
for await (const [data] of ee.toIterable('add', { signal: opts.signal })) {
const post = data as Post;
yield tracked(post.id, post);
}
}),
});
To stop from the server, simply return from the generator. On the client, call .unsubscribe().
Client over SSE
import { createTRPCClient, httpBatchLink, httpSubscriptionLink, splitLink } from '@trpc/client';
const trpcClient = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({ url: '/api/trpc' }),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});
SSE server tuning (in initTRPC.create):
const t = initTRPC.create({
sse: {
maxDurationMs: 60_000,
ping: { enabled: true, intervalMs: 2_000 },
client: { reconnectAfterInactivityMs: 3_000 },
},
});
Client over WebSockets
Create a WS server with the ws adapter:
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext,
keepAlive: { enabled: true, pingMs: 30000, pongWaitMs: 5000 },
});
process.on('SIGTERM', () => {
handler.broadcastReconnectNotification(); // ask clients to reconnect
wss.close();
});
Client:
import { createTRPCClient, createWSClient, wsLink } from '@trpc/client';
const wsClient = createWSClient({ url: 'ws://localhost:3001' });
const client = createTRPCClient<AppRouter>({
links: [wsLink({ client: wsClient })],
});
createWSClient options include url, connectionParams, WebSocket (ponyfill), retryDelayMs, onOpen/onClose/onError, lazy ({ enabled, closeMs }), and keepAlive ({ enabled, intervalMs, pongTimeoutMs }).
Authenticating subscriptions
- Web, same domain: cookies are sent automatically.
- SSE cross-domain:
httpSubscriptionLink({ eventSourceOptions: () => ({ withCredentials: true }) }). - Custom headers (non-web): ponyfill
EventSourceand useeventSourceOptions. - Connection params: pass
connectionParams(WS first message / SSE URL param), read viaopts.info.connectionParamsincreateContext.
Caution
EventSourcecannot re-runeventSourceOptions()/url()on reconnect, so expired auth won't refresh on its own. PairhttpSubscriptionLinkwith aretryLinkto re-establish the connection with fresh config. Restarting recreates theEventSource, so previously tracked events are lost.
10. Server-Side Calls
Call your procedures directly on the server (for cron jobs, REST endpoints, or integration tests) with createCallerFactory:
const createCaller = t.createCallerFactory(appRouter);
const caller = createCaller({ foo: 'bar' }); // pass your Context
const addedPost = await caller.post.add({ title: 'My post' });
const postList = await caller.post.list();
Or the convenience router.createCaller(ctx):
const caller = appRouter.createCaller({ user: { id: 'KATT' } });
const result = await caller.secret();
Both accept an onError option for handling errors.
Important
Do not use
createCallerto call procedures from within other procedures — it re-creates context, re-runs middleware, and re-validates input. Instead, extract shared logic into a plain function and call that from both procedures.
11. Inferring Types
Reuse your router's types on the client without duplicating them.
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from './server/router';
export type Inputs = inferRouterInputs<AppRouter>;
export type Outputs = inferRouterOutputs<AppRouter>;
Per-procedure helpers:
| Helper | From | Use |
|---|---|---|
inferRouterInputs<AppRouter> |
@trpc/server |
All input types of a router. |
inferRouterOutputs<AppRouter> |
@trpc/server |
All output types of a router. |
inferProcedureInput<typeof router.x> |
@trpc/server |
Single procedure input. |
inferProcedureOutput<...> |
@trpc/server |
Single procedure output. |
inferProcedureBuilderResolverOptions<typeof baseProcedure> |
@trpc/server |
The opts type of a base procedure (handy for extracting handlers). |
inferInput / inferOutput |
@trpc/tanstack-react-query |
Infer input/output from a trpc.path.to.procedure proxy. |
12. Common Tasks (Recipes)
Add a typed REST-ish GET endpoint — queries are GETs already; pass input as a URI-encoded JSON ?input=. To force POST, set methodOverride: 'POST' on the link (and allow it server-side).
Cache public query responses — use the adapter's responseMeta to set HTTP cache headers, but only when no personal data is in the batch:
createHTTPServer({
router: appRouter,
responseMeta(opts) {
const allPublic = opts.paths?.every((p) => p.includes('public'));
const allOk = opts.errors.length === 0;
if (allPublic && allOk && opts.type === 'query') {
return {
headers: new Headers([
['cache-control', `s-maxage=1, stale-while-revalidate=86400`],
]),
};
}
return {};
},
});
Caution
Because batching is on by default, a cache header set in
responseMetaapplies to the whole batch. Use asplitLinkto separate public/cacheable calls from private ones, or omit cache headers when an auth header/cookie is present.
Use multiple validators in a chain — stack .input() (objects merge) and use middleware to act on the shared input.
Build a plugin — define a separate initTRPC instance and expose procedures via .concat().
Infinite/paginated queries — give the procedure a cursor input and use infiniteQueryOptions on the client.
13. Troubleshooting
Error: You're trying to use @trpc/server in a non-server environment. This is not supported by default.
Cause: initTRPC.create() ran where window exists (or NODE_ENV !== 'test'), e.g. you imported server code into a client bundle.
Solution: Import only the type of your router on the client (import type { AppRouter }). Never import the router value client-side. For tests, set allowOutsideOfServer: true.
Prevention: Keep the t = initTRPC.create() instance in server-only files; export helpers, not t.
Error: No fetch implementation found
Cause: The client couldn't find a global fetch (older Node, or an environment without window.fetch/globalThis.fetch).
Solution: Pass a fetch ponyfill to your link: httpBatchLink({ url, fetch: myFetch }) (e.g. from undici or node-fetch).
Prevention: Use Node 18+ which ships a global fetch.
Error: 413 Payload Too Large / 414 URI Too Long / 404 on batched GETs
Cause: Too many or too-large operations batched into a single GET URL.
Solution: Set maxURLLength (e.g. 2083) or methodOverride: 'POST' on httpBatchLink. Cap with maxItems (client) + maxBatchSize (server).
Prevention: Keep query inputs small; configure limits up front.
Error: 400 Bad Request on a batch
Cause: The batch exceeded the server's maxBatchSize.
Solution: Lower the client's maxItems to match the server's maxBatchSize.
Prevention: Keep maxItems ≤ server maxBatchSize.
Output validation throws INTERNAL_SERVER_ERROR instead of a client error
Cause: Your .output() validator rejected the resolver's return value. Output failures are deliberately treated as server errors.
Solution: Fix the resolver to return data matching the output schema, or relax the schema.
Prevention: Keep output validators in sync with what resolvers actually return.
CORS errors / cookies not sent cross-origin
Cause: CORS not enabled on the server, and/or credentials not included on the client.
Solution: Enable CORS on the adapter (e.g. middleware: cors() for Standalone) and set fetch(url, { ...options, credentials: 'include' }) on the client link.
Prevention: Configure CORS strictly (not wildcard) in production.
Date/Map/Set come back as strings/plain objects
Cause: No data transformer configured (default JSON drops these types).
Solution: Add the same transformer (e.g. superjson) to initTRPC.create({ transformer }) and every client link.
Prevention: Set the transformer at project start; TypeScript flags missing link transformers.
Subscription drops and doesn't resume / loses events on reconnect
Cause: Events aren't tracked(), or auth expired and EventSource can't refresh its config.
Solution: Wrap yielded events with tracked(id, data) and accept lastEventId in input; pair httpSubscriptionLink with a retryLink for auth refresh.
Prevention: Set up the event listener before fetching the backlog so you don't miss events while replaying.
React 19: proxy coercion error in createInnerProxy
Cause: A known interaction with React 19's proxy coercion (#7335).
Solution: Upgrade to a tRPC v11 release that includes the fix.
Prevention: Keep @trpc/* packages current.
npm install trpc installs the wrong thing
Cause: The bare trpc npm name is not the official package and is not under the @trpc org (#7344).
Solution: Install the scoped packages: @trpc/server, @trpc/client, etc.
Prevention: Always use the @trpc/* scope.
TypeScript errors about "inferred type cannot be named"
Cause: A type referencing internal tRPC types couldn't be named without importing them.
Solution: This is what the unstable-core-do-not-import glue is for — usually resolved by importing the relevant type from @trpc/server. If you hit a genuine gap, open an issue.
Prevention: Use the public type helpers (inferRouter*, inferProcedure*).
14. FAQ
Q: Do I need GraphQL or REST knowledge to use tRPC? No. tRPC is "just functions." You call procedures and get typed results. There's no schema language or codegen.
Q: Does tRPC require a build step or code generation?
No. Type safety comes purely from importing the AppRouter type. Change the server and the client types update immediately.
Q: Is tRPC tied to React or Next.js?
No. The core (@trpc/server + @trpc/client) is framework-agnostic. React/Next integrations are optional add-on packages, and there are community adapters for other frameworks.
Q: How does the client stay typed without importing server code?
The client uses import type { AppRouter }, which is erased at build time. Only type information crosses the boundary — no server logic ships to the browser.
Q: What's the difference between a query and a mutation?
Mostly semantic. Queries use GET and are for reads; mutations use POST and are for writes/side effects. On the client you call .query() vs .mutate().
Q: Which link should I use?
Start with httpBatchLink (batches calls into one request). Use httpBatchStreamLink for streaming/async-generators, httpLink to disable batching, wsLink/httpSubscriptionLink for subscriptions, and splitLink to route by operation type.
Q: Do I have to use Zod? No. Any Standard Schema library works (Valibot, ArkType, effect, …), as do Yup, Superstruct, Typia, TypeBox, and even a plain validation function. Zod is the default recommendation.
Q: Do I need a separate WebSocket server for subscriptions?
Not necessarily. SSE (httpSubscriptionLink) works over your normal HTTP adapter. Use WebSockets (wsLink + @trpc/server/adapters/ws) when you need bidirectional/WS-specific behavior.
Q: Which React integration should I pick?
@trpc/tanstack-react-query (the factory-based, TanStack-native client) is recommended for new projects. The classic @trpc/react-query (createTRPCReact) is still supported.
Q: How do I send auth tokens?
Use the headers callback on an HTTP link, or connectionParams for WS/SSE. Cookies are sent automatically same-origin; cross-origin needs CORS + credentials: 'include'.
Q: Can I call procedures from the server itself (tests, cron)?
Yes — createCallerFactory(router)(ctx) or router.createCaller(ctx). Don't use it to call procedures from inside other procedures; extract shared functions instead.
Q: How do I cache responses?
Queries are GETs, so set HTTP cache headers via the adapter's responseMeta — but be careful with batching and personal data. Use splitLink to separate public/cacheable calls.
Q: What runtimes are supported? Node, Deno, Bun, Cloudflare Workers, Vercel Edge, AWS Lambda, and any WinterCG-compatible runtime via the Fetch adapter — plus the browser.
Q: How do I upgrade between versions / migrate code?
tRPC ships an upgrade codemod CLI: npx @trpc/upgrade. See your version's migration notes.
Q: Can I expose REST/OpenAPI endpoints?
Use procedure meta together with an OpenAPI integration (e.g. trpc-openapi / the in-repo @trpc/openapi tooling) to generate REST-compatible endpoints.
15. Package & Adapter Reference
Public packages
| Package | Key exports |
|---|---|
@trpc/server |
initTRPC, TRPCError, tracked, isTrackedEnvelope, lazy, getHTTPStatusCodeFromError (via /http), inferRouterInputs, inferRouterOutputs, inferProcedureInput, inferProcedureOutput, inferProcedureBuilderResolverOptions |
@trpc/server/adapters/* |
standalone, express, fastify, fetch, aws-lambda, next, next-app-dir, node-http, ws |
@trpc/server/observable |
observable (for custom links) |
@trpc/client |
createTRPCClient, createTRPCUntypedClient, createWSClient, TRPCClientError, isTRPCClientError, getFetch, and links: httpLink, httpBatchLink, httpBatchStreamLink, httpSubscriptionLink, wsLink, splitLink, loggerLink, retryLink, localLink |
@trpc/react-query |
createTRPCReact, createTRPCQueryUtils, getQueryKey, getMutationKey, createServerSideHelpers (via /server), createHydrationHelpers (via /rsc) |
@trpc/tanstack-react-query |
createTRPCContext, createTRPCOptionsProxy, useSubscription, inferInput, inferOutput |
@trpc/next |
createTRPCNext, withTRPC, ssrPrepass (via /ssrPrepass) |
@trpc/upgrade |
Codemod CLI for upgrading tRPC code between versions |
Adapter handlers at a glance
| Adapter | Handler / function | Context options type |
|---|---|---|
| Standalone | createHTTPServer, createHTTPHandler, createHTTP2Handler |
CreateHTTPContextOptions, CreateHTTP2ContextOptions |
| Express | createExpressMiddleware |
CreateExpressContextOptions |
| Fastify | fastifyTRPCPlugin |
CreateFastifyContextOptions |
| Fetch | fetchRequestHandler |
FetchCreateContextFnOptions |
| AWS Lambda | awsLambdaRequestHandler |
CreateAWSLambdaContextOptions |
| Next.js | createNextApiHandler |
(Next API req/res) |
| WebSockets | applyWSSHandler |
CreateWSSContextFnOptions |
Common adapter options
All adapters accept some combination of: router, createContext, onError, responseMeta, allowBatching, maxBatchSize, and adapter-specific options (e.g. basePath for Standalone, endpoint for Fetch, keepAlive for WS).