Skip to content

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