From d45edcb674182d269b1189f86aa8334d628ef390 Mon Sep 17 00:00:00 2001 From: Suhaib Janjua Date: Tue, 17 Mar 2026 16:23:03 +0500 Subject: [PATCH] feat: add trpc-fullstack skill for end-to-end type-safe API development (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add trpc-fullstack skill for end-to-end type-safe API development * fix: separate App Router and server-side context factories per review feedback - Replace createContext({ req } as any) in App Router handler with createTRPCContext(opts: FetchCreateContextFnOptions) — the correct fetch adapter shape - Add createServerContext() for Server Component callers so auth() is called directly without an empty or synthetic request object cast - Update SSR example to use createServerContext() instead of createContext({} as any) - Add two new pitfall entries covering both auth failure scenarios --- skills/trpc-fullstack/SKILL.md | 461 +++++++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 skills/trpc-fullstack/SKILL.md diff --git a/skills/trpc-fullstack/SKILL.md b/skills/trpc-fullstack/SKILL.md new file mode 100644 index 00000000..517a4d5c --- /dev/null +++ b/skills/trpc-fullstack/SKILL.md @@ -0,0 +1,461 @@ +--- +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 ( +
+ +