--- name: trpc-fullstack description: "Build end-to-end type-safe APIs with tRPC — routers, procedures, middleware, subscriptions, and Next.js/React integration patterns." category: framework risk: none source: community date_added: "2026-03-17" author: suhaibjanjua tags: [typescript, trpc, api, fullstack, nextjs, react, type-safety] tools: [claude, cursor, gemini] --- # tRPC Full-Stack ## Overview tRPC lets you build fully type-safe APIs without writing a schema or code-generation step. Your TypeScript types flow from the server router directly to the client — so every API call is autocompleted, validated at compile time, and refactoring-safe. Use this skill when building TypeScript monorepos, Next.js apps, or any project where the server and client share a codebase. ## When to Use This Skill - Use when building a TypeScript full-stack app (Next.js, Remix, Express + React) where the client and server share a single repo - Use when you want end-to-end type safety on API calls without REST/GraphQL schema overhead - Use when adding real-time features (subscriptions) to an existing tRPC setup - Use when designing multi-step middleware (auth, rate limiting, tenant scoping) on tRPC procedures - Use when migrating an existing REST/GraphQL API to tRPC incrementally ## Core Concepts ### Routers and Procedures A **router** groups related **procedures** (think: endpoints). Procedures are typed functions — `query` for reads, `mutation` for writes, `subscription` for real-time streams. ### Input Validation with Zod All procedure inputs are validated with Zod schemas. The validated, typed input is available in the procedure handler — no manual parsing. ### Context `context` is shared state passed to every procedure — auth session, database client, request headers, etc. It is built once per request in a context factory. **Important:** Next.js App Router and Pages Router require separate context factories because App Router handlers receive a fetch `Request`, not a Node.js `NextApiRequest`. ### Middleware Middleware chains run before a procedure. Use them for authentication, logging, and request enrichment. They can extend the context for downstream procedures. --- ## How It Works ### Step 1: Install and Initialize ```bash npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod ``` Create the tRPC instance and reusable builders: ```typescript // src/server/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; import { type Context } from './context'; import { ZodError } from 'zod'; const t = initTRPC.context().create({ 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; ``` ### Step 2: Define Two Context Factories Next.js App Router handlers receive a fetch `Request` (not a Node.js `NextApiRequest`), so the context must be built differently depending on the call site. Define one factory per surface: ```typescript // src/server/context.ts import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'; import { auth } from '@/server/auth'; // Next-Auth v5 / your auth helper import { db } from './db'; /** * Context for the HTTP handler (App Router Route Handler). * `opts.req` is the fetch Request — auth is resolved server-side via `auth()`. */ export async function createTRPCContext(opts: FetchCreateContextFnOptions) { const session = await auth(); // server-side auth — no req/res needed return { session, db, headers: opts.req.headers }; } /** * Context for direct server-side callers (Server Components, RSC, cron jobs). * No HTTP request is involved, so we call auth() directly from the server. */ export async function createServerContext() { const session = await auth(); return { session, db }; } export type Context = Awaited>; ``` ### Step 3: Build an Auth Middleware and Protected Procedure ```typescript // src/server/trpc.ts (continued) const enforceAuth = middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { // Narrows type: session is non-null from here session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceAuth); ``` ### Step 4: Create Routers ```typescript // src/server/routers/post.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc'; import { TRPCError } from '@trpc/server'; export const postRouter = router({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: 'desc' }, }); const nextCursor = posts.length > input.limit ? posts.pop()!.id : undefined; return { posts, nextCursor }; }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new TRPCError({ code: 'NOT_FOUND' }); return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(200), body: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const post = await ctx.db.post.findUnique({ where: { id: input.id } }); if (!post) throw new TRPCError({ code: 'NOT_FOUND' }); if (post.authorId !== ctx.session.user.id) throw new TRPCError({ code: 'FORBIDDEN' }); return ctx.db.post.delete({ where: { id: input.id } }); }), }); ``` ### Step 5: Compose the Root Router and Export Types ```typescript // src/server/root.ts import { router } from './trpc'; import { postRouter } from './routers/post'; import { userRouter } from './routers/user'; export const appRouter = router({ post: postRouter, user: userRouter, }); // Export the type for the client — never import the appRouter itself on the client export type AppRouter = typeof appRouter; ``` ### Step 6: Mount the API Handler (Next.js App Router) The App Router handler must use `fetchRequestHandler` and the **fetch-based** context factory. `createTRPCContext` receives `FetchCreateContextFnOptions` (with a fetch `Request`), not a Pages Router `req/res` pair. ```typescript // src/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'; import { appRouter } from '@/server/root'; import { createTRPCContext } from '@/server/context'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, // opts is FetchCreateContextFnOptions — req is the fetch Request createContext: (opts: FetchCreateContextFnOptions) => createTRPCContext(opts), }); export { handler as GET, handler as POST }; ``` ### Step 7: Set Up the Client (React Query) ```typescript // src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/root'; export const trpc = createTRPCReact(); ``` ```typescript // src/app/providers.tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { useState } from 'react'; import { trpc } from '@/utils/trpc'; export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', headers: () => ({ 'x-trpc-source': 'react' }), }), ], }) ); return ( {children} ); } ``` --- ## Examples ### Example 1: Fetching Data in a Component ```typescript // components/PostList.tsx 'use client'; import { trpc } from '@/utils/trpc'; export function PostList() { const { data, isLoading, error } = trpc.post.list.useQuery({ limit: 10 }); if (isLoading) return

Loading…

; if (error) return

Error: {error.message}

; return (
    {data?.posts.map((post) => (
  • {post.title}
  • ))}
); } ``` ### Example 2: Mutation with Cache Invalidation ```typescript 'use client'; import { trpc } from '@/utils/trpc'; export function CreatePost() { const utils = trpc.useUtils(); const createPost = trpc.post.create.useMutation({ onSuccess: () => { // Invalidate and refetch the post list utils.post.list.invalidate(); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const form = e.currentTarget; const data = new FormData(form); createPost.mutate({ title: data.get('title') as string, body: data.get('body') as string, }); form.reset(); }; return (