From antigravity-awesome-skills
Build end-to-end type-safe APIs with tRPC — routers, procedures, middleware, subscriptions, and Next.js/React integration patterns.
npx claudepluginhub mit-network/antigravity-awesome-skillsThis skill uses the workspace's default tool permissions.
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.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
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.
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;
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>>;
// 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);
// 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 } });
}),
});
// 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.
// 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 };
// 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>
);
}
// 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>
);
}
'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:
// 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>
);
}
// 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);
},
});
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 onlyProblem: Auth session is null in protected procedures even when the user is logged in
Solution: 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
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 to createContext
Problem: "Type error: AppRouter is not assignable to AnyRouter"
Solution: Import AppRouter as a type import (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() in onSuccess to trigger a refetch via React Query
Problem: "Cannot find module '@trpc/server/adapters/next'" with App Router
Solution: Use @trpc/server/adapters/fetch and fetchRequestHandler for the App Router; the nextjs adapter is for Pages Router only
Problem: Subscriptions not connecting
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 gaps