TRPC
"End-to-end type-safe APIs with tRPC: routers, procedures, middleware, context, React Query integration, subscriptions, and Next.js App Router patterns"
tRPC enables end-to-end type safety between your server and client without code generation or schema definitions. You define procedures on the server and call them on the client with full autocompletion and type inference. The API contract is the TypeScript types themselves — no REST endpoints to document, no GraphQL schemas to maintain. Changes on the server immediately surface as type errors on the client. ## Key Points - Use superjson as the transformer so Dates, Maps, Sets serialize correctly across the wire. - Keep routers small and focused — one per domain entity — then merge them in `_app.ts`. - Define reusable input schemas as standalone Zod objects for sharing between procedures. - Use `useUtils()` to access the tRPC-aware React Query cache for optimistic updates and targeted invalidation. - Prefer `httpBatchLink` to batch multiple requests into a single HTTP call. - Type the `AppRouter` export and import it on the client — this is the sole bridge for end-to-end safety. - Use the server-side caller (`createCaller`) in Next.js Server Components instead of HTTP round-trips. - **Importing server code on the client.** Only import the `AppRouter` type — never actual server modules. - **Skipping input validation.** Always use Zod schemas on every procedure; unvalidated inputs bypass tRPC's safety guarantees. - **Massive monolithic routers.** Split routers by domain; a single 500-line router is hard to maintain and test. - **Ignoring error formatting.** Without a custom `errorFormatter`, Zod validation errors arrive as opaque messages on the client. - **Using tRPC for public APIs consumed by third parties.** tRPC is designed for internal client-server communication; use REST or GraphQL for external consumers.
skilldb get api-frameworks-skills/TRPCFull skill: 304 linestRPC
Core Philosophy
tRPC enables end-to-end type safety between your server and client without code generation or schema definitions. You define procedures on the server and call them on the client with full autocompletion and type inference. The API contract is the TypeScript types themselves — no REST endpoints to document, no GraphQL schemas to maintain. Changes on the server immediately surface as type errors on the client.
tRPC works best in monorepos or full-stack frameworks where server and client share the same TypeScript project. It pairs naturally with React Query for data fetching and Next.js for full-stack applications.
Setup
Server Installation and Configuration
// Install: npm install @trpc/server @trpc/client @trpc/react-query @trpc/next zod
// server/trpc.ts — Initialize tRPC with context
import { initTRPC, TRPCError } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";
import { getServerSession } from "next-auth";
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const session = await getServerSession(opts.req, opts.res);
return {
session,
db: prisma,
req: opts.req,
};
};
type Context = Awaited<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
Client Setup with React Query
// utils/trpc.ts — Client configuration
import { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
export function getTRPCClient() {
return trpc.createClient({
links: [
loggerLink({ enabled: () => process.env.NODE_ENV === "development" }),
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
headers() {
return { "x-trpc-source": "react" };
},
}),
],
});
}
Key Techniques
Router and Procedure Definitions
// server/routers/posts.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const postRouter = router({
// Query — fetch data
getAll: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().nullish(),
category: z.enum(["tech", "design", "business"]).optional(),
})
)
.query(async ({ input, ctx }) => {
const posts = await ctx.db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: input.category ? { category: input.category } : undefined,
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true, image: true } } },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const next = posts.pop();
nextCursor = next?.id;
}
return { posts, nextCursor };
}),
// Mutation — modify data
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
category: z.enum(["tech", "design", "business"]),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.session.user.id },
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return ctx.db.post.delete({ where: { id: input.id } });
}),
});
Middleware and Protected Procedures
// server/trpc.ts — Auth and rate-limiting middleware
const isAuthed = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { session: ctx.session } });
});
const rateLimit = middleware(async ({ ctx, next, path }) => {
const key = `ratelimit:${ctx.session?.user?.id ?? ctx.req.socket.remoteAddress}:${path}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
if (count > 30) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Rate limit exceeded" });
}
return next();
});
export const protectedProcedure = publicProcedure.use(isAuthed);
export const rateLimitedProcedure = publicProcedure.use(rateLimit);
Merging Routers
// server/routers/_app.ts
import { router } from "../trpc";
import { postRouter } from "./posts";
import { userRouter } from "./users";
import { commentRouter } from "./comments";
export const appRouter = router({
post: postRouter,
user: userRouter,
comment: commentRouter,
});
export type AppRouter = typeof appRouter;
React Query Integration on Client
// components/PostList.tsx
import { trpc } from "@/utils/trpc";
export function PostList() {
const utils = trpc.useUtils();
const { data, fetchNextPage, hasNextPage, isLoading } =
trpc.post.getAll.useInfiniteQuery(
{ limit: 20, category: "tech" },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.getAll.invalidate();
},
onError: (error) => {
if (error.data?.zodError) {
console.error("Validation errors:", error.data.zodError.fieldErrors);
}
},
});
const handleSubmit = (formData: FormData) => {
createPost.mutate({
title: formData.get("title") as string,
content: formData.get("content") as string,
category: "tech",
});
};
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.pages.flatMap((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{hasNextPage && <button onClick={() => fetchNextPage()}>Load more</button>}
</div>
);
}
Next.js App Router Integration
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ req }),
});
export { handler as GET, handler as POST };
// Server-side caller for RSC
import { createCallerFactory } from "@trpc/server";
const createCaller = createCallerFactory(appRouter);
export async function getServerCaller() {
const ctx = await createTRPCContext();
return createCaller(ctx);
}
Subscriptions with WebSockets
// server/routers/chat.ts
import { observable } from "@trpc/server/observable";
export const chatRouter = router({
onMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<{ text: string; author: string }>((emit) => {
const handler = (msg: ChatMessage) => emit.next(msg);
eventEmitter.on(`room:${input.roomId}`, handler);
return () => eventEmitter.off(`room:${input.roomId}`, handler);
});
}),
});
Best Practices
- Use superjson as the transformer so Dates, Maps, Sets serialize correctly across the wire.
- Keep routers small and focused — one per domain entity — then merge them in
_app.ts. - Define reusable input schemas as standalone Zod objects for sharing between procedures.
- Use
useUtils()to access the tRPC-aware React Query cache for optimistic updates and targeted invalidation. - Prefer
httpBatchLinkto batch multiple requests into a single HTTP call. - Type the
AppRouterexport and import it on the client — this is the sole bridge for end-to-end safety. - Use the server-side caller (
createCaller) in Next.js Server Components instead of HTTP round-trips.
Anti-Patterns
- Importing server code on the client. Only import the
AppRoutertype — never actual server modules. - Skipping input validation. Always use Zod schemas on every procedure; unvalidated inputs bypass tRPC's safety guarantees.
- Massive monolithic routers. Split routers by domain; a single 500-line router is hard to maintain and test.
- Ignoring error formatting. Without a custom
errorFormatter, Zod validation errors arrive as opaque messages on the client. - Using tRPC for public APIs consumed by third parties. tRPC is designed for internal client-server communication; use REST or GraphQL for external consumers.
- Calling mutations inside
useEffect. Use event handlers or form actions to trigger mutations; effects cause duplicate calls in StrictMode.
Install this skill directly: skilldb add api-frameworks-skills
Related Skills
Elysia
"Elysia: Bun-native web framework with type-safe routing, TypeBox validation, plugins, Eden treaty client, lifecycle hooks, and Swagger documentation"
Express.js
"Express.js with TypeScript: routing, middleware patterns, error handling, validation, authentication, static files, CORS, and production-ready configuration"
Fastify
Fastify: high-performance Node.js web framework with schema-based validation, logging, plugin architecture, and TypeScript support
Apollo GraphQL
"Apollo GraphQL: schema design, resolvers, Apollo Server, Apollo Client with React, useQuery/useMutation, caching strategies, subscriptions, and codegen"
Hono
"Hono: ultra-fast web framework for edge, serverless, and Node.js — middleware, routing, Zod validation, JWT, CORS, RPC client, and JSX support"
Koa
Koa: lightweight Node.js framework by the Express team with async/await middleware, context object, and composable architecture