tRPC v11 Reference: Routers, Procedures, Next.js, React Query & Type Safety
tRPC v11 lets you build fully type-safe APIs without code generation or schemas. The type flows from server procedure to client call automatically — if you change a server input type, the client immediately shows a TypeScript error. It’s not REST and it’s not GraphQL; it’s RPC with zero overhead when server and client are in the same TypeScript monorepo. The core concept: procedures (queries + mutations) live in routers that get exported as a type, which the client imports and uses directly.
1. Router Setup & Procedures
Initialize tRPC, define router, queries, mutations, and export AppRouter type
// npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// server/trpc.ts — initialize tRPC:
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
// Context type — available in all procedures:
type Context = {
userId: string | null;
db: PrismaClient;
};
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const procedure = t.procedure; // unauthenticated
export const publicProcedure = t.procedure;
// Protected procedure — reusable middleware:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, userId: ctx.userId } }); // ctx.userId is now non-null
});
// server/routers/users.ts:
export const usersRouter = router({
// Query — read data:
list: publicProcedure
.input(z.object({ page: z.number().int().min(1).default(1), limit: z.number().max(100).default(20) }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findMany({ skip: (input.page - 1) * input.limit, take: input.limit });
}),
byId: publicProcedure
.input(z.string().uuid())
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({ where: { id: input } });
if (!user) throw new TRPCError({ code: "NOT_FOUND" });
return user;
}),
// Mutation — write data:
create: protectedProcedure
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({ data: input });
}),
delete: protectedProcedure
.input(z.string().uuid())
.mutation(async ({ input, ctx }) => {
return ctx.db.user.delete({ where: { id: input } });
}),
});
// server/router.ts — merge routers:
export const appRouter = router({ users: usersRouter, posts: postsRouter });
export type AppRouter = typeof appRouter; // ← export this type to client
2. Next.js App Router Integration
app/api/trpc route handler, context creation, and server-side caller
// app/api/trpc/[trpc]/route.ts — Next.js App Router handler:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/router";
import { createContext } from "@/server/context";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
});
export { handler as GET, handler as POST };
// server/context.ts — create context per request:
export async function createContext(req: Request) {
const session = await getSession(req); // your auth solution
return {
userId: session?.userId ?? null,
db: prisma,
};
}
// Server-side caller — call procedures directly in Server Components (no HTTP):
// utils/server.ts:
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "@/server/router";
const createCaller = createCallerFactory(appRouter);
export const serverCaller = createCaller({
userId: null, // or pass session
db: prisma,
});
// In a Server Component:
// app/users/page.tsx:
export default async function UsersPage() {
const users = await serverCaller.users.list({ page: 1 });
// TypeScript knows the return type — no fetch, no serialization
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
3. React Client with TanStack Query
Client setup, useQuery, useMutation, optimistic updates, and invalidation
// utils/trpc.ts — create typed client:
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/router";
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx — wrap app with query client + tRPC provider:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/utils/trpc";
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: "/api/trpc" })], // batches multiple calls into one request
});
export function Providers({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// In a Client Component:
"use client";
export function UsersList() {
const { data, isLoading, error } = trpc.users.list.useQuery({ page: 1 });
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
trpc.users.list.invalidate(); // refetch list after mutation
},
onMutate: async (newUser) => {
// Optimistic update:
await utils.users.list.cancel();
const prev = utils.users.list.getData();
utils.users.list.setData({ page: 1 }, (old) => [...(old ?? []), { ...newUser, id: "temp" }]);
return { prev };
},
onError: (err, newUser, context) => {
utils.users.list.setData({ page: 1 }, context?.prev); // rollback
},
});
return <button onClick={() => createUser.mutate({ name: "Alice", email: "a@b.com" })}>Add User</button>;
}
4. Middleware, Error Handling & Subscriptions
Reusable middleware for logging/auth, TRPCError codes, and WebSocket subscriptions
import { initTRPC, TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
// Middleware — compose logic across procedures:
const timingMiddleware = 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;
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const allowed = await redis.rateLimiter.check(ctx.userId ?? ctx.ip);
if (!allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Slow down" });
return next();
});
// Chain middleware:
export const tracedProcedure = t.procedure.use(timingMiddleware).use(rateLimitMiddleware);
// TRPCError codes → HTTP status mapping:
// BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403),
// NOT_FOUND (404), CONFLICT (409), PRECONDITION_FAILED (412),
// PAYLOAD_TOO_LARGE (413), UNPROCESSABLE_CONTENT (422),
// TOO_MANY_REQUESTS (429), INTERNAL_SERVER_ERROR (500)
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
cause: originalError, // original error for server logs
});
// Subscriptions (requires WebSocket link):
export const chatRouter = router({
onMessage: procedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const unsub = pubsub.subscribe(`room:${input.roomId}`, (msg) => emit.next(msg));
return () => unsub(); // cleanup on unsubscribe
});
}),
});
// Client: const { data } = trpc.chat.onMessage.useSubscription({ roomId: "abc" });
5. Input Validation, Output Types & Testing
Zod schemas for input/output, type inference, and testing procedures directly
import { z } from "zod";
// Input + output schema (output prevents leaking sensitive fields):
const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string() });
// Never expose: passwordHash, internalNotes, adminFlags
export const usersRouter = router({
profile: protectedProcedure
.input(z.string().uuid())
.output(userSchema) // runtime + compile-time output enforcement
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({ where: { id: input } });
if (!user) throw new TRPCError({ code: "NOT_FOUND" });
return user; // extra fields are stripped by output schema
}),
});
// Infer input/output types from router:
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
type RouterInput = inferRouterInputs<AppRouter>;
type RouterOutput = inferRouterOutputs<AppRouter>;
type CreateUserInput = RouterInput["users"]["create"]; // { name: string; email: string }
type UserListOutput = RouterOutput["users"]["list"]; // User[]
// Testing — call procedures directly (no HTTP, no client):
import { createCallerFactory } from "@trpc/server";
const createCaller = createCallerFactory(appRouter);
test("users.list returns paginated users", async () => {
const caller = createCaller({ userId: "user-123", db: testDb });
const result = await caller.users.list({ page: 1, limit: 10 });
expect(result).toHaveLength(10);
});
test("users.create requires auth", async () => {
const caller = createCaller({ userId: null, db: testDb });
await expect(caller.users.create({ name: "X", email: "x@x.com" }))
.rejects.toThrow("UNAUTHORIZED");
});
Track TypeScript and Node.js releases at ReleaseRun. Related: Next.js App Router Reference | Zod Reference | Prisma Reference | TypeScript EOL Tracker
🔍 Free tool: npm Package Health Checker — check @trpc/server, @trpc/client, and related packages for known CVEs and active maintenance.
Founded
2023 in London, UK
Contact
hello@releaserun.com