trpc
End-to-end typesafe APIs without schemas
$ npx docs2skills add trpc-typesafe-apistRPC
End-to-end typesafe APIs without schemas or code generation
What this skill does
tRPC enables you to build fully typesafe APIs by sharing TypeScript types directly between client and server. Unlike GraphQL or OpenAPI, tRPC leverages TypeScript's type system to provide compile-time safety without schemas, code generation, or runtime type checking. When you define API procedures on the server, the client automatically gets full type inference for inputs, outputs, and errors.
tRPC works by creating "procedures" (functions) on the server that can be called from the client with full type safety. It handles serialization, HTTP transport, error handling, and type inference automatically. This eliminates the common problem of API contracts becoming out-of-sync between frontend and backend in full-stack TypeScript applications.
The library is framework-agnostic with adapters for Next.js, Express, Fastify, and more. It supports queries, mutations, and real-time subscriptions while maintaining a tiny bundle size and zero runtime dependencies.
Prerequisites
- TypeScript 4.1+ (for template literal types)
- Node.js 16+ for server
- A supported framework (Next.js, Express, Fastify, etc.)
- Shared TypeScript workspace or monorepo structure
Quick start
npm install @trpc/server @trpc/client @trpc/react-query
Server setup:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello, ${input.name}!` };
}),
createUser: t.procedure
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(({ input }) => {
// Database logic here
return { id: 1, ...input };
}),
});
export type AppRouter = typeof appRouter;
Client setup:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
// Fully typed calls
const result = await client.hello.query({ name: 'World' });
const user = await client.createUser.mutate({ name: 'Alice', email: 'alice@example.com' });
Core concepts
Router: Container for all your API procedures. Routers can be nested and merged to organize endpoints.
Procedure: Individual API endpoint that can be a query (read) or mutation (write). Procedures have input validation, middleware, and output transformation.
Context: Shared data available to all procedures (database connections, user authentication, etc.). Created per-request.
Middleware: Functions that run before procedures for authentication, logging, or data transformation. Middleware can modify context and chain together.
Links: Client-side transport layer that handles HTTP requests, batching, caching, and error handling. Multiple links can be composed.
Proxy Client: Type-safe client that mirrors your router structure, providing auto-complete and type checking for all API calls.
Key API surface
Server:
initTRPC.create()- Initialize tRPC instancet.router({ ... })- Define router with procedurest.procedure- Create base procedure.input(schema)- Add input validation (usually Zod).query(handler)- Define read operation.mutation(handler)- Define write operation.subscription(handler)- Define real-time subscription.use(middleware)- Add middleware to procedure
Client:
createTRPCProxyClient<AppRouter>()- Create typed clienthttpBatchLink()- HTTP transport with batchingwsLink()- WebSocket transport for subscriptionsclient.procedure.query(input)- Call query procedureclient.procedure.mutate(input)- Call mutation procedureclient.procedure.subscribe(input, callbacks)- Subscribe to real-time updates
React Query integration:
createTRPCReact<AppRouter>()- Create React hookstrpc.procedure.useQuery()- React Query query hooktrpc.procedure.useMutation()- React Query mutation hooktrpc.procedure.useSubscription()- Real-time subscription hook
Common patterns
Authentication middleware:
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.user } });
});
const protectedProcedure = t.procedure.use(isAuthed);
Nested routers:
const userRouter = t.router({
create: t.procedure.input(userSchema).mutation(({ input }) => { /* ... */ }),
getById: t.procedure.input(z.string()).query(({ input }) => { /* ... */ }),
});
const appRouter = t.router({
user: userRouter,
post: postRouter,
});
Error handling:
import { TRPCError } from '@trpc/server';
const createPost = t.procedure
.input(postSchema)
.mutation(async ({ input }) => {
const post = await db.post.create(input);
if (!post) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create post',
});
}
return post;
});
Subscriptions:
import { observable } from '@trpc/server/observable';
const onPostAdd = t.procedure.subscription(() => {
return observable<Post>((emit) => {
const onAdd = (data: Post) => emit.next(data);
eventEmitter.on('add', onAdd);
return () => eventEmitter.off('add', onAdd);
});
});
React integration:
const trpc = createTRPCReact<AppRouter>();
function MyComponent() {
const { data, isLoading } = trpc.hello.useQuery({ name: 'World' });
const createUser = trpc.createUser.useMutation({
onSuccess: () => {
// Invalidate and refetch
trpc.useContext().user.getAll.invalidate();
},
});
return <div>{data?.greeting}</div>;
}
Configuration
Server context:
export const createContext = ({ req, res }: CreateNextContextOptions) => {
return {
req,
res,
db: prisma,
user: getUser(req),
};
};
const t = initTRPC.context<typeof createContext>().create();
Client configuration:
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => ({ authorization: getAuthToken() }),
maxBatchSize: 10,
}),
],
transformer: superjson, // For Date, Map, Set serialization
});
Error formatting:
const t = initTRPC.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
Best practices
- Use Zod for input validation - Provides runtime safety and automatic TypeScript inference
- Structure routers by feature - Group related procedures together, use router merging
- Implement proper error handling - Use TRPCError with appropriate codes (BAD_REQUEST, UNAUTHORIZED, etc.)
- Add authentication middleware - Create reusable middleware for protected procedures
- Batch requests - Use httpBatchLink to automatically combine simultaneous requests
- Transform data appropriately - Use superjson for complex types like Date objects
- Implement request/response logging - Add middleware for observability in production
- Type your context properly - Define context interface for better DX across procedures
- Use subscriptions sparingly - Only for real-time features, prefer polling for simple cases
- Version your APIs carefully - Breaking changes require coordinated client/server updates
Gotchas and common mistakes
Type import/export: Always export router as type to avoid bundling server code in client: export type AppRouter = typeof appRouter;
Context typing: Context must be typed during tRPC initialization, not per-procedure. Use initTRPC.context<ContextType>().create()
Input validation: Without .input(), procedures accept no parameters. Empty input requires .input(z.void()) or .input(z.object({}))
Middleware order: Middleware runs in definition order. Authentication middleware must come before procedures that need user context
Error serialization: Custom error properties won't serialize unless you extend TRPCError or use error formatting
Subscription cleanup: Always return cleanup function from subscription observables to prevent memory leaks
Batching limitations: Requests to different endpoints or with different headers won't batch together
Server/client mismatch: Runtime errors occur if client calls procedures that don't exist on current server version
Circular dependencies: Importing router type in files that the router imports creates circular dependency issues
Middleware context: Middleware can only add to context, not remove. Use TypeScript assertion if you need to narrow types
Transformer sync: Client and server must use identical transformers (superjson versions, configuration)
Query key conflicts: Multiple useQuery calls with same input share cache. Use unique keys for different data needs
Subscription connection limits: WebSocket subscriptions maintain persistent connections. Monitor and limit concurrent subscriptions
Build-time requirements: tRPC requires shared TypeScript compilation. Separate API packages need careful build coordination