Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build end-to-end type-safe APIs with tRPC — routers, procedures, middleware, subscriptions, and Next.js/React integration patterns.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 141% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 133% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 260% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 102% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 267% | 0% |
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.
A router groups related procedures (think: endpoints). Procedures are typed functions — query for reads, mutation for writes, subscription for real-time streams.
All procedure inputs are validated with Zod schemas. The validated, typed input is available in the procedure handler — no manual parsing.
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 chains run before a procedure. Use them for authentication, logging, and request enrichment. They can extend the context for downstream procedures.
bashnpm 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<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;
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<ReturnType<typeof createTRPCContext>>;
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);
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 } }); }), });
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;
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 };
typescript// src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/root'; export const trpc = createTRPCReact<AppRouter>();
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 ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider> ); }
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 <p>Loading…</p>; if (error) return <p>Error: {error.message}</p>; return ( <ul> {data?.posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }
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<HTMLFormElement>) => { 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 ( <form onSubmit={handleSubmit}> <input name="title" placeholder="Title" required /> <textarea name="body" placeholder="Body" required /> <button type="submit" disabled={createPost.isPending}> {createPost.isPending ? 'Creating…' : 'Create Post'} </button> {createPost.error && <p>{createPost.error.message}</p>} </form> ); }
Use createServerContext — the dedicated server-side factory — so that auth() is called correctly without needing a synthetic or empty request object:
typescript// app/posts/page.tsx (Next.js Server Component) import { appRouter } from '@/server/root'; import { createCallerFactory } from '@trpc/server'; import { createServerContext } from '@/server/context'; const createCaller = createCallerFactory(appRouter); export default async function PostsPage() { // Uses createServerContext — calls auth() server-side, no req/res cast needed const caller = createCaller(await createServerContext()); const { posts } = await caller.post.list({ limit: 20 }); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }
typescript// server/routers/notifications.ts import { observable } from '@trpc/server/observable'; import { EventEmitter } from 'events'; const ee = new EventEmitter(); export const notificationRouter = router({ onNew: protectedProcedure.subscription(({ ctx }) => { return observable<{ message: string; at: Date }>((emit) => { const onNotification = (data: { message: string }) => { emit.next({ message: data.message, at: new Date() }); }; const channel = `user:${ctx.session.user.id}`; ee.on(channel, onNotification); return () => ee.off(channel, onNotification); }); }), });
typescript// Client usage — requires wsLink in the client config trpc.notification.onNew.useSubscription(undefined, { onData(data) { toast(data.message); }, });
AppRouter type from server code — never import appRouter on the clientcreateTRPCContext for the HTTP handler, createServerContext for Server Components and callersinput without a schemaroot.tsutils.invalidate() after mutations to keep the cache freshas any to silence type errors — the mismatch will surface as a runtime failure when auth or session lookups return undefinedcreateContext({} as any) in Server Components — use createServerContext() which calls auth() directlyprotectedProcedure — never rely on client-side checks aloneTRPCError with a public-safe message and keep stack traces server-side onlynull in protected procedures even when the user is logged inSolution: Ensure createTRPCContext uses the correct server-side auth call (e.g. auth() from Next-Auth v5) and is not receiving a Pages Router req/res cast via as any in an App Router handler
Solution: Use createServerContext() (the dedicated server-side factory) instead of passing an empty or synthetic object to createContext
Solution: Import AppRouter as a type import (import type { AppRouter }) on the client, not the full module
Solution: Call utils.<router>.<procedure>.invalidate() in onSuccess to trigger a refetch via React Query
Solution: Use @trpc/server/adapters/fetch and fetchRequestHandler for the App Router; the nextjs adapter is for Pages Router only
Solution: Subscriptions require splitLink — route subscriptions to wsLink and queries/mutations to httpBatchLink
@typescript-expert — Deep TypeScript patterns used inside tRPC routers and generic utilities@react-patterns — React hooks patterns that pair with trpc.*.useQuery and useMutation@test-driven-development — Write procedure unit tests using createCallerFactory without an HTTP server@security-auditor — Review tRPC middleware chains for auth bypass and input validation gapsOther measured skills in the registry, with their headline benchmark lift.