trpc-fullstack
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
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Create the tRPC instance and reusable builders:
// 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;
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:
// 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>>;
Step 3: Build an Auth Middleware and Protected Procedure
// 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
// 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
// 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.
// 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)
// src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/root'; export const trpc = createTRPCReact<AppRouter>();
// 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> ); }
Examples
Example 1: Fetching Data in a Component
// 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> ); }
Example 2: Mutation with Cache Invalidation
'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> ); }
Example 3: Server-Side Caller (Server Components / SSR)
Use createServerContext — the dedicated server-side factory — so that auth() is called
correctly without needing a synthetic or empty request object:
// 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> ); }
Example 4: Real-Time Subscriptions (WebSocket)
// 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); }); }), });
// Client usage — requires wsLink in the client config trpc.notification.onNew.useSubscription(undefined, { onData(data) { toast(data.message); }, });
Best Practices
- ✅ Export only
AppRoutertype from server code — never importappRouteron the client - ✅ Use separate context factories —
createTRPCContextfor the HTTP handler,createServerContextfor Server Components and callers - ✅ Validate all inputs with Zod — never trust raw
inputwithout a schema - ✅ Split routers by domain (posts, users, billing) and merge in
root.ts - ✅ Extend context in middleware rather than querying the DB multiple times per request
- ✅ Use
utils.invalidate()after mutations to keep the cache fresh - ❌ Don't cast context with
as anyto silence type errors — the mismatch will surface as a runtime failure when auth or session lookups return undefined - ❌ Don't use
createContext({} as any)in Server Components — usecreateServerContext()which callsauth()directly - ❌ Don't put business logic in the route handler — keep it in the procedure or a service layer
- ❌ Don't share the tRPC client instance globally — create it per-provider to avoid stale closures
Security & Safety Notes
- Always enforce authorization in
protectedProcedure— never rely on client-side checks alone - Validate all input shapes with Zod, including pagination cursors and IDs, to prevent injection via malformed inputs
- Avoid exposing internal error details to clients — use
TRPCErrorwith a public-safemessageand keep stack traces server-side only - Rate-limit public procedures using middleware to prevent abuse
Common Pitfalls
-
Problem: Auth session is
nullin protected procedures even when the user is logged in Solution: EnsurecreateTRPCContextuses the correct server-side auth call (e.g.auth()from Next-Auth v5) and is not receiving a Pages Routerreq/rescast viaas anyin an App Router handler -
Problem: Server Component caller fails for auth-dependent queries Solution: Use
createServerContext()(the dedicated server-side factory) instead of passing an empty or synthetic object tocreateContext -
Problem: "Type error: AppRouter is not assignable to AnyRouter" Solution: Import
AppRouteras atypeimport (import type { AppRouter }) on the client, not the full module -
Problem: Mutations not reflecting in the UI after success Solution: Call
utils.<router>.<procedure>.invalidate()inonSuccessto trigger a refetch via React Query -
Problem: "Cannot find module '@trpc/server/adapters/next'" with App Router Solution: Use
@trpc/server/adapters/fetchandfetchRequestHandlerfor the App Router; thenextjsadapter is for Pages Router only -
Problem: Subscriptions not connecting Solution: Subscriptions require
splitLink— route subscriptions towsLinkand queries/mutations tohttpBatchLink
Related Skills
@typescript-expert— Deep TypeScript patterns used inside tRPC routers and generic utilities@react-patterns— React hooks patterns that pair withtrpc.*.useQueryanduseMutation@test-driven-development— Write procedure unit tests usingcreateCallerFactorywithout an HTTP server@security-auditor— Review tRPC middleware chains for auth bypass and input validation gaps
Additional Resources
- tRPC Official Docs
- create-t3-app — Production Next.js starter with tRPC wired in
- tRPC GitHub
- TanStack Query Docs