
Trpc
- 67 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
trpc is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- trpc
- AI & Agent Building
- AI-coding skill
Trpc by the numbers
- 67 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,935 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill trpcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
tRPC
Overview
tRPC enables end-to-end type-safe APIs by sharing TypeScript types between server and client without code generation or schemas. The server defines procedures (queries, mutations, subscriptions) in a router, and the client infers all types from the exported AppRouter type.
When to use: TypeScript full-stack apps needing type-safe API layers, monorepos sharing types, real-time subscriptions, rapid API iteration without OpenAPI/GraphQL overhead.
When NOT to use: Public APIs consumed by non-TypeScript clients (use REST/GraphQL), polyglot backends, projects requiring OpenAPI spec generation as primary output.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Initialize | initTRPC.context<Ctx>().create() | Single entry point, configure once |
| Router | t.router({ ... }) | Nest routers with t.mergeRouters() |
| Query | t.procedure.input(schema).query(fn) | Read operations, cached by clients |
| Mutation | t.procedure.input(schema).mutation(fn) | Write operations |
| Subscription | t.procedure.subscription(fn) | Real-time via WebSocket or SSE |
| Middleware | t.middleware(fn) | Chain with .use(), extend context via next({ ctx }) |
| Standalone middleware | experimental_standaloneMiddleware<{}>() | Reusable with explicit type constraints |
| Context | createContext({ req, res }) | Per-request, passed to all procedures |
| Server caller | t.createCallerFactory(router) | Type-safe server-side procedure calls |
| Error | throw new TRPCError({ code, message }) | Mapped to HTTP status codes |
| Error formatter | initTRPC.create({ errorFormatter }) | Customize error shape, expose Zod errors |
| Input validation | .input(zodSchema) | Multiple .input() calls merge (intersection) |
| Output validation | .output(zodSchema) | Strip extra fields from responses |
| React hooks | trpc.useQuery() / trpc.useMutation() | Built on @tanstack/react-query |
| React utils | trpc.useUtils() | Invalidate, prefetch, setData on cache |
| Suspense query | trpc.useSuspenseQuery() | Suspense-compatible data fetching |
| React subscription | trpc.useSubscription(input, opts) | onData callback for real-time events |
| Vanilla client | createTRPCClient<AppRouter>({ links }) | No React dependency |
| Batch link | httpBatchLink({ url }) | Batches requests in single HTTP call |
| Stream link | httpBatchStreamLink({ url }) | Streams responses as they resolve |
| WS link | wsLink({ client: wsClient }) | WebSocket transport for subscriptions |
| Split link | splitLink({ condition, true, false }) | Route operations to different transports |
| Logger link | loggerLink() | Debug request/response in development |
| Data transformer | initTRPC.create({ transformer }) | Serialize Dates, Maps, Sets (superjson) |
| Fetch adapter | fetchRequestHandler({ endpoint, req }) | Cloudflare Workers, Deno, Bun, Next.js |
| Procedure chaining | publicProcedure.use(auth).use(rateLimit) | Build reusable procedure bases |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Exporting the full t object | Export only t.router, t.procedure, t.middleware, t.createCallerFactory |
| Creating context inside procedure | Create context in adapter setup, flows through automatically |
Using any for context type | Define context type with initTRPC.context<MyContext>().create() |
| Catching errors in procedures | Let errors propagate; use onError in adapter or error formatter |
| Importing server code on client | Import only type AppRouter, never runtime server code |
Using createCaller in production request handlers | Use createCallerFactory for type-safe reusable callers |
| Defining input without validation | Always validate with Zod, Valibot, or ArkType schemas |
| Nesting routers incorrectly | Use dot notation in keys or t.mergeRouters(), not deep nesting |
| Different transformers on client/server | Both sides must use the same transformer (e.g., superjson) |
| Creating QueryClient inside component render | Create once outside component or use useState initializer |
| Manual TypeScript generics on hooks | Type the router procedures; let inference propagate to client |
Not passing signal to fetch in queryFn | tRPC React handles abort signals automatically |
Installation
pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zodFor subscriptions, add ws (server) and configure wsLink (client). For SSE-based subscriptions, use httpBatchStreamLink instead.
Delegation
- Procedure discovery: Use
Exploreagent - Router architecture review: Use
Taskagent - Code review: Delegate to
code-revieweragent
If the tanstack-query skill is available, delegate query/mutation caching, invalidation, and optimistic update patterns to it.If the zod-validation skill is available, delegate input schema design and validation patterns to it.If the drizzle-orm skill is available, delegate database query patterns within procedures to it.If the vitest-testing skill is available, delegate test runner configuration and assertion patterns to it.If the hono skill is available, delegate Hono framework routing and middleware patterns to it.References
- Router and procedure definitions
- Middleware and context patterns
- Error handling and formatting
- React client integration with @trpc/react-query
- Server-side callers and vanilla client
- Adapter setup for standalone, Express, Fastify, and Hono
- Subscriptions and real-time patterns
- Testing tRPC procedures
Adapter Setup
Standalone HTTP Server
Minimal setup with no framework dependency:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './routers/_app';
import { createContext } from './context';
const server = createHTTPServer({
router: appRouter,
createContext,
onError({ error, path }) {
console.error(`Error on ${path}:`, error.message);
},
});
server.listen(3000);With CORS
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import cors from 'cors';
const server = createHTTPServer({
middleware: cors(),
router: appRouter,
createContext,
});Express Adapter
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './routers/_app';
function createContext({ req, res }: trpcExpress.CreateExpressContextOptions) {
return {
req,
res,
session: req.session,
};
}
const app = express();
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
onError({ error, path }) {
console.error(`Error on ${path}:`, error.message);
},
}),
);
app.listen(3000);Fastify Adapter
import fastify from 'fastify';
import {
fastifyTRPCPlugin,
type FastifyTRPCPluginOptions,
} from '@trpc/server/adapters/fastify';
import { appRouter, type AppRouter } from './routers/_app';
import { createContext } from './context';
const server = fastify({ maxParamLength: 5000 });
server.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
onError({ path, error }) {
console.error(`Error on ${path}:`, error.message);
},
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
});
async function start() {
try {
await server.listen({ port: 3000 });
} catch (err) {
server.log.error(err);
process.exit(1);
}
}
start();Hono Adapter
import { Hono } from 'hono';
import { trpcServer } from '@hono/trpc-server';
import { appRouter } from './routers/_app';
const app = new Hono();
app.use(
'/trpc/*',
trpcServer({
router: appRouter,
createContext: (_opts, c) => ({
req: c.req,
}),
}),
);
export default app;Next.js App Router
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/routers/_app';
import { createContext } from '~/server/context';
function handler(req: Request) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
}
export { handler as GET, handler as POST };Fetch Adapter (Generic)
Works with any runtime supporting the Web Fetch API (Cloudflare Workers, Deno, Bun):
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from './routers/_app';
export default {
async fetch(request: Request): Promise<Response> {
return fetchRequestHandler({
endpoint: '/trpc',
req: request,
router: appRouter,
createContext: () => ({}),
});
},
};Common Configuration
All adapters share these options:
| Option | Description |
|---|---|
router | The root AppRouter instance |
createContext | Factory function creating per-request context |
onError | Error callback with { error, path, type, ctx, input } |
batching.enabled | Enable/disable request batching (default: true) |
responseMeta | Customize response headers and status codes |
Error Handling
TRPCError
All tRPC errors extend TRPCError with a required code:
import { TRPCError } from '@trpc/server';
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
cause: originalError,
});Error Codes
| Code | HTTP Status | Usage |
|---|---|---|
BAD_REQUEST | 400 | Invalid input or parameters |
UNAUTHORIZED | 401 | Missing or invalid authentication |
FORBIDDEN | 403 | Authenticated but not authorized |
NOT_FOUND | 404 | Resource does not exist |
METHOD_NOT_SUPPORTED | 405 | Wrong procedure type |
TIMEOUT | 408 | Operation timed out |
CONFLICT | 409 | Resource conflict |
PRECONDITION_FAILED | 412 | Precondition not met |
PAYLOAD_TOO_LARGE | 413 | Request body too large |
UNPROCESSABLE_CONTENT | 422 | Semantic validation failure |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
CLIENT_CLOSED_REQUEST | 499 | Client disconnected |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
Error Formatter
Customize the error shape returned to clients:
import { initTRPC } from '@trpc/server';
import { ZodError } from 'zod';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});Client receives structured Zod errors for validation failures:
const mutation = trpc.userCreate.useMutation();
if (mutation.error?.data?.zodError) {
const fieldErrors = mutation.error.data.zodError.fieldErrors;
}onError Callback
Handle errors at the adapter level for logging and monitoring:
import { createHTTPServer } from '@trpc/server/adapters/standalone';
createHTTPServer({
router: appRouter,
onError({ error, path, type, ctx, input }) {
console.error(`tRPC error on ${type} ${path}:`, error.message);
if (error.code === 'INTERNAL_SERVER_ERROR') {
sentry.captureException(error);
}
},
});Error Handling in Procedures
Let errors propagate naturally; avoid try/catch unless transforming errors:
const getUser = publicProcedure.input(z.string()).query(async ({ input }) => {
const user = await db.user.findById(input);
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input} not found`,
});
}
return user;
});Wrapping External Errors
Convert third-party errors into TRPCError:
const chargeUser = protectedProcedure
.input(z.object({ amount: z.number().positive() }))
.mutation(async ({ input, ctx }) => {
try {
return await stripe.charges.create({
amount: input.amount,
customer: ctx.user.stripeId,
});
} catch (err) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Payment failed',
cause: err,
});
}
});HTTP Status Code Extraction
Convert TRPCError codes to HTTP status codes for non-tRPC consumers:
import { TRPCError } from '@trpc/server';
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
try {
const result = await caller.user.byId('nonexistent');
} catch (cause) {
if (cause instanceof TRPCError) {
const httpCode = getHTTPStatusCodeFromError(cause);
res.status(httpCode).json({ error: cause.message });
}
}Middleware and Context
Context Creation
Context is created per-request and passed to all procedures:
import { initTRPC } from '@trpc/server';
import { type CreateExpressContextOptions } from '@trpc/server/adapters/express';
export async function createContext({ req, res }: CreateExpressContextOptions) {
const session = await getSession(req.headers.authorization);
return {
session,
db,
};
}
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();Basic Middleware
Middleware runs before the procedure and can modify context:
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const durationMs = Date.now() - start;
console.log(`${type} ${path} - ${durationMs}ms`);
return result;
});
export const loggedProcedure = t.procedure.use(logger);Auth Middleware with Context Extension
Extend context by returning new values from next({ ctx }):
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.session.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Downstream procedures receive ctx.user with proper types.
Chaining Middleware
Middleware chains execute in order:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin only' });
}
return next();
});
export const adminProcedure = t.procedure.use(isAuthed).use(isAdmin);Standalone Middleware
Reusable middleware with explicit type constraints:
import { experimental_standaloneMiddleware, TRPCError } from '@trpc/server';
import { z } from 'zod';
const projectAccessMiddleware = experimental_standaloneMiddleware<{
ctx: { allowedProjects: string[] };
input: { projectId: string };
}>().create(({ ctx, input, next }) => {
if (!ctx.allowedProjects.includes(input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No project access' });
}
return next();
});
const projectProcedure = protectedProcedure
.input(z.object({ projectId: z.string() }))
.use(projectAccessMiddleware);Standalone middleware validates that the procedure context and input satisfy its constraints at the type level.
Rate Limiting Middleware
import { TRPCError } from '@trpc/server';
const rateLimit = t.middleware(async ({ ctx, path, next }) => {
const key = `${ctx.user.id}:${path}`;
const allowed = await rateLimiter.check(key);
if (!allowed) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
return next();
});Timing Middleware with Headers
const timing = t.middleware(async ({ next }) => {
const start = Date.now();
const result = await next();
result.ok &&
result.ctx.resHeaders?.set(
'Server-Timing',
`proc;dur=${Date.now() - start}`,
);
return result;
});Organization Pattern
Keep middleware in dedicated files and export reusable procedure bases:
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.session.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);React Integration
Setup
Create tRPC React Hooks
// src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();Provider Configuration
// src/app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '../utils/trpc';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000 },
},
});
}
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === 'undefined') return makeQueryClient();
return (browserQueryClient ??= makeQueryClient());
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}Query Hooks
function UserList() {
const { data, isPending, error } = trpc.user.list.useQuery();
if (isPending) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Query with Input
function UserProfile({ userId }: { userId: string }) {
const { data } = trpc.user.byId.useQuery(userId);
return data ? <h1>{data.name}</h1> : null;
}Conditional Queries
function UserPosts({ userId }: { userId: string | undefined }) {
const { data } = trpc.post.byUser.useQuery(userId!, {
enabled: !!userId,
});
return data ? <PostList posts={data} /> : null;
}Suspense Queries
function UserProfile({ userId }: { userId: string }) {
const [data] = trpc.user.byId.useSuspenseQuery(userId);
return <h1>{data.name}</h1>;
}Mutation Hooks
function CreateUser() {
const utils = trpc.useUtils();
const mutation = trpc.user.create.useMutation({
onSuccess() {
utils.user.list.invalidate();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
}}
>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Optimistic Updates
const utils = trpc.useUtils();
const mutation = trpc.todo.toggle.useMutation({
async onMutate({ id, completed }) {
await utils.todo.list.cancel();
const previous = utils.todo.list.getData();
utils.todo.list.setData(undefined, (old) =>
old?.map((t) => (t.id === id ? { ...t, completed } : t)),
);
return { previous };
},
onError(_err, _vars, context) {
if (context?.previous) {
utils.todo.list.setData(undefined, context.previous);
}
},
onSettled() {
utils.todo.list.invalidate();
},
});useUtils
trpc.useUtils() provides access to the query client scoped to tRPC:
const utils = trpc.useUtils();
utils.user.list.invalidate();
utils.user.byId.prefetch('user-123');
utils.user.list.setData(undefined, newData);
utils.user.byId.getData('user-123');Streaming with httpBatchStreamLink
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchStreamLink({
url: '/api/trpc',
}),
],
}),
);Responses stream as individual procedures resolve, improving perceived performance for batched requests.
Aborting Requests
tRPC React Query passes abort signals automatically. Requests cancel on component unmount or query key changes.
const { data } = trpc.search.useQuery(searchTerm, {
trpc: { abortOnUnmount: true },
});Router and Procedures
Initialization
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;Basic Router
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
userList: publicProcedure.query(async () => {
return db.user.findMany();
}),
userById: publicProcedure.input(z.string()).query(async ({ input }) => {
return db.user.findById(input);
}),
userCreate: publicProcedure
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.mutation(async ({ input }) => {
return db.user.create(input);
}),
});
export type AppRouter = typeof appRouter;Input Validation
Procedures accept any Zod schema (or Valibot/ArkType) via .input():
const createPost = publicProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string(),
published: z.boolean().default(false),
tags: z.array(z.string()).optional(),
}),
)
.mutation(async ({ input }) => {
return db.post.create({ data: input });
});Multiple .input() calls merge schemas (intersection):
const updatePost = publicProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({ title: z.string().optional(), content: z.string().optional() }),
)
.mutation(async ({ input }) => {
return db.post.update({ where: { id: input.id }, data: input });
});Output Validation
Use .output() to validate and strip extra fields from procedure responses:
const getUser = publicProcedure
.input(z.string())
.output(
z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
)
.query(async ({ input }) => {
return db.user.findById(input);
});Nested Routers
Organize procedures into sub-routers using dot notation in keys or nested router() calls:
const userRouter = router({
list: publicProcedure.query(async () => db.user.findMany()),
byId: publicProcedure
.input(z.string())
.query(async ({ input }) => db.user.findById(input)),
create: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => db.user.create(input)),
});
const postRouter = router({
list: publicProcedure.query(async () => db.post.findMany()),
byId: publicProcedure
.input(z.string())
.query(async ({ input }) => db.post.findById(input)),
});
export const appRouter = router({
user: userRouter,
post: postRouter,
});Client calls use dot notation: trpc.user.list.useQuery().
Merge Routers
Flatten multiple routers into a single namespace:
import { t } from './trpc';
const mergedRouter = t.mergeRouters(analyticsRouter, billingRouter);All procedures from both routers share the same namespace.
Procedure Chaining
Build reusable procedure bases by chaining middleware and input:
const authedProcedure = publicProcedure.use(isAuthed);
const adminProcedure = authedProcedure.use(isAdmin);
const adminCreateUser = adminProcedure
.input(z.object({ name: z.string(), role: z.enum(['user', 'admin']) }))
.mutation(async ({ input, ctx }) => {
return db.user.create({ data: { ...input, createdBy: ctx.user.id } });
});Server-Side Callers and Vanilla Client
createCallerFactory
The recommended way to call procedures from the server:
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.context<Context>().create();
export const createCallerFactory = t.createCallerFactory;// src/server/routers/_app.ts
import { createCallerFactory, router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
export const createCaller = createCallerFactory(appRouter);
export type AppRouter = typeof appRouter;Using Server-Side Callers
import { createContext } from '../context';
import { createCaller } from '../routers/_app';
async function handleWebhook(req: Request) {
const ctx = await createContext({ req });
const caller = createCaller(ctx);
const user = await caller.user.byId('user-123');
await caller.post.create({ title: 'From webhook', authorId: user.id });
}Next.js Server Component Usage
import { createCaller } from '~/server/routers/_app';
import { createContext } from '~/server/context';
export default async function UsersPage() {
const ctx = await createContext();
const caller = createCaller(ctx);
const users = await caller.user.list();
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Error Handling with Callers
import { TRPCError } from '@trpc/server';
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
try {
const result = await caller.user.byId('nonexistent');
} catch (cause) {
if (cause instanceof TRPCError) {
const httpCode = getHTTPStatusCodeFromError(cause);
return new Response(cause.message, { status: httpCode });
}
return new Response('Internal error', { status: 500 });
}Vanilla Client (No React)
For non-React environments or Node.js scripts:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { type AppRouter } from '../server/routers/_app';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
const users = await client.user.list.query();
const newUser = await client.user.create.mutate({
name: 'Alice',
email: 'alice@example.com',
});Custom Links
Links form a chain that transforms requests and responses:
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [
loggerLink({
enabled: (opts) =>
(process.env.NODE_ENV === 'development' &&
typeof window !== 'undefined') ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc',
headers() {
return {
authorization: `Bearer ${getToken()}`,
};
},
}),
],
});Split Link
Route different procedure types to different transports:
import {
createTRPCClient,
httpBatchLink,
splitLink,
wsLink,
createWSClient,
} from '@trpc/client';
const wsClient = createWSClient({
url: 'ws://localhost:3001',
});
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ client: wsClient }),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});Data Transformers
Use superjson to serialize Dates, Maps, Sets, and other non-JSON types:
// Server
import superjson from 'superjson';
const t = initTRPC.create({
transformer: superjson,
});// Client
import superjson from 'superjson';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
});Both server and client must use the same transformer.
Subscriptions
Defining Subscriptions
Subscriptions use the subscription procedure type and return an observable or async iterable:
import { observable } from '@trpc/server/observable';
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const appRouter = router({
onPostCreated: publicProcedure.subscription(() => {
return observable<{ id: string; title: string }>((emit) => {
const handler = (data: { id: string; title: string }) => {
emit.next(data);
};
ee.on('post.created', handler);
return () => {
ee.off('post.created', handler);
};
});
}),
createPost: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(async ({ input }) => {
const post = await db.post.create({ data: input });
ee.emit('post.created', post);
return post;
}),
});Subscriptions with Input
const onMessageInRoom = publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const handler = (msg: Message) => {
if (msg.roomId === input.roomId) {
emit.next(msg);
}
};
ee.on('message', handler);
return () => {
ee.off('message', handler);
};
});
});WebSocket Server Setup
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import { WebSocketServer } from 'ws';
import { appRouter } from './routers/_app';
import { createContext } from './context';
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext,
});
process.on('SIGTERM', () => {
handler.broadcastReconnectNotification();
wss.close();
});Client WebSocket Configuration
import { createTRPCClient, createWSClient, wsLink } from '@trpc/client';
import { type AppRouter } from '../server/routers/_app';
const wsClient = createWSClient({
url: 'ws://localhost:3001',
});
const client = createTRPCClient<AppRouter>({
links: [wsLink({ client: wsClient })],
});Split Link for Mixed Transport
Route subscriptions over WebSocket, everything else over HTTP:
import {
createTRPCClient,
httpBatchLink,
splitLink,
wsLink,
createWSClient,
} from '@trpc/client';
const wsClient = createWSClient({ url: 'ws://localhost:3001' });
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ client: wsClient }),
false: httpBatchLink({ url: 'http://localhost:3000/trpc' }),
}),
],
});Subscribing from React
function PostFeed() {
const [posts, setPosts] = useState<Post[]>([]);
trpc.onPostCreated.useSubscription(undefined, {
onData(post) {
setPosts((prev) => [post, ...prev]);
},
onError(err) {
console.error('Subscription error:', err);
},
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Server-Sent Events (SSE)
Alternative to WebSocket using HTTP streaming:
import { httpBatchStreamLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchStreamLink({
url: '/api/trpc',
}),
],
});SSE subscriptions work through the standard HTTP adapter without a separate WebSocket server.
Async Iterable Subscriptions
Modern alternative to the observable pattern:
const onTick = publicProcedure.subscription(async function* () {
let i = 0;
while (true) {
yield { tick: i++ };
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});Async iterables automatically clean up when the client disconnects.
Testing Patterns
Unit Testing with createCallerFactory
The recommended approach for testing individual procedures:
import { describe, expect, it } from 'vitest';
import { appRouter, createCaller } from '../routers/_app';
describe('user router', () => {
it('creates a user', async () => {
const caller = createCaller({
session: null,
db: testDb,
});
const user = await caller.user.create({
name: 'Alice',
email: 'alice@example.com',
});
expect(user).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
expect(user.id).toBeDefined();
});
it('lists users', async () => {
const caller = createCaller({
session: null,
db: testDb,
});
const users = await caller.user.list();
expect(users).toBeInstanceOf(Array);
});
});Testing Protected Procedures
Mock the auth context to test procedures behind middleware:
import { describe, expect, it } from 'vitest';
import { TRPCError } from '@trpc/server';
describe('protected routes', () => {
it('rejects unauthenticated requests', async () => {
const caller = createCaller({
session: null,
db: testDb,
});
await expect(caller.post.create({ title: 'Test' })).rejects.toThrow(
TRPCError,
);
await expect(caller.post.create({ title: 'Test' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('allows authenticated requests', async () => {
const caller = createCaller({
session: { user: { id: 'user-1', role: 'user' } },
db: testDb,
});
const post = await caller.post.create({ title: 'Test Post' });
expect(post.title).toBe('Test Post');
expect(post.authorId).toBe('user-1');
});
});Testing Input Validation
import { describe, expect, it } from 'vitest';
import { TRPCError } from '@trpc/server';
describe('input validation', () => {
it('rejects invalid email', async () => {
const caller = createCaller({ session: null, db: testDb });
await expect(
caller.user.create({ name: 'Alice', email: 'not-an-email' }),
).rejects.toThrow(TRPCError);
});
it('rejects empty name', async () => {
const caller = createCaller({ session: null, db: testDb });
await expect(
caller.user.create({ name: '', email: 'alice@example.com' }),
).rejects.toThrow(TRPCError);
});
});Integration Testing with HTTP
Test the full request/response cycle:
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { type AppRouter, appRouter } from '../routers/_app';
describe('API integration', () => {
let server: ReturnType<typeof createHTTPServer>;
let client: ReturnType<typeof createTRPCClient<AppRouter>>;
beforeAll(() => {
server = createHTTPServer({
router: appRouter,
createContext: () => ({ session: null, db: testDb }),
});
server.listen(0);
const { port } = server.server.address() as { port: number };
client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: `http://localhost:${port}` })],
});
});
afterAll(() => {
server.server.close();
});
it('creates and retrieves a user', async () => {
const created = await client.user.create.mutate({
name: 'Bob',
email: 'bob@example.com',
});
const fetched = await client.user.byId.query(created.id);
expect(fetched).toMatchObject({ name: 'Bob' });
});
});Testing with a Helper Factory
Create a reusable test helper:
import { createCaller } from '../routers/_app';
function createTestCaller(overrides?: Partial<Context>) {
return createCaller({
session: null,
db: testDb,
...overrides,
});
}
function createAuthenticatedCaller(
user: { id: string; role: string } = { id: 'test-user', role: 'user' },
) {
return createTestCaller({
session: { user },
});
}describe('admin routes', () => {
it('allows admin access', async () => {
const caller = createAuthenticatedCaller({ id: 'admin-1', role: 'admin' });
const users = await caller.admin.listAllUsers();
expect(users).toBeDefined();
});
it('blocks non-admin access', async () => {
const caller = createAuthenticatedCaller({ id: 'user-1', role: 'user' });
await expect(caller.admin.listAllUsers()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});Testing Error Formatting
import { describe, expect, it } from 'vitest';
describe('error formatting', () => {
it('returns Zod errors for invalid input', async () => {
const caller = createCaller({ session: null, db: testDb });
try {
await caller.user.create({ name: '', email: 'invalid' });
} catch (err) {
expect(err).toBeInstanceOf(TRPCError);
expect((err as TRPCError).code).toBe('BAD_REQUEST');
}
});
});