Skip to main content
Technology & EngineeringApi Frameworks304 lines

TRPC

"End-to-end type-safe APIs with tRPC: routers, procedures, middleware, context, React Query integration, subscriptions, and Next.js App Router patterns"

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

tRPC

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 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.

Anti-Patterns

  • 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.
  • 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

Get CLI access →