tRPC APIs skill for Codex
End-to-end types without leaking data or auth. Codex builds tRPC routers with auth and tenancy in reusable procedure builders, validated inputs, output schemas that stop database rows leaking, and TRPCError codes that map to proper HTTP statuses. On the client it follows the project's integration, preferring v11 queryOptions with explicit invalidation, configures batching limits and transformers on both sides, and tests procedures through server-side callers.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- trpc-apis Installs to ~/.agents/skills/trpc-apis/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install trpc-apis The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project trpc-apis Installs to .agents/skills/trpc-apis/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/trpc-apis.zipunzip -o trpc-apis.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) trpc-apis PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $trpc-apis, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds type-safe TypeScript APIs with tRPC v11 - initTRPC context, routers and procedures, input and output validation with Zod or any Standard Schema, auth middleware that narrows context, TRPCError codes, httpBatchLink and httpBatchStreamLink with superjson, SSE subscriptions, Next.js App Router and fetch adapters, server-side callers, and the TanStack React Query integration (queryOptions, mutationOptions, invalidation). Use when adding procedures, wiring tRPC into Next.js or a React app, fixing type inference or batching issues, or deciding between tRPC and REST.
Use it when
- A TypeScript monorepo needs typed client-server calls
- Adding procedures, middleware or routers to an existing tRPC app
- Migrating from classic tRPC React hooks to the TanStack Query integration
- Debugging batching, transformer or type inference problems
Use something else when
- The API has external or non-TypeScript consumers: use openapi-first
- The question is React Query caching in general: use tanstack-query
How to check the result
Caller-based tests reject unauthenticated and cross-org calls, tsc fails on the client after a server field rename, and batched requests stay under the URL limit.
The mistake it prevents
Returning raw database rows without an output schema, which ships columns such as password hashes or internal flags to the browser.
Done well means: procedures are thin and typed end to end, every input is validated, auth
lives in reusable procedure builders rather than inside handlers, errors use TRPCError codes
that map to sane HTTP statuses, and the client uses React Query options so caching and
invalidation stay explicit.
When tRPC fits
| Situation | Choice |
|---|---|
| TypeScript frontend and backend in one repo or monorepo | tRPC |
| Public API, third-party consumers, mobile apps in other languages | REST with OpenAPI (openapi-first) or GraphQL |
| Mixed: internal app plus a few public endpoints | tRPC for the app, plain REST routes for the public surface |
tRPC’s contract is the TypeScript type of the router. If a consumer cannot import that type, tRPC gives it nothing.
Before changing anything
- Read
AGENTS.md; check versions:rg -n "\"@trpc/|\"@tanstack/react-query\"|\"zod\"|\"superjson\"" package.json */package.json. - Identify the client integration in use:
@trpc/tanstack-react-query(v11 recommended,useTRPC()+queryOptions) or classic@trpc/react-query(trpc.x.useQuery). Follow the one the project uses; do not mix both in new code. - Find the
initTRPCfile and existing procedure builders (publicProcedure,protectedProcedure).
Server setup
// server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError, z } from "zod";
export async function createContext({ req }: { req: Request }) {
const session = await getSession(req.headers); // your auth library
return { db, session };
}
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? z.flattenError(error.cause) : null,
},
};
},
});
export const router = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.session.user } }); // narrowed: user is non-null downstream
});
export const orgProcedure = protectedProcedure
.input(z.object({ orgId: z.uuid() }))
.use(async ({ ctx, input, next }) => {
const member = await ctx.db.membership.find(ctx.user.id, input.orgId);
if (!member) throw new TRPCError({ code: "FORBIDDEN" });
return next({ ctx: { ...ctx, member } });
});
Inputs chain: a procedure built on orgProcedure can add .input(z.object({ invoiceId: z.uuid() }))
and receives both fields merged.
// server/routers/invoice.ts
export const invoiceRouter = router({
byId: orgProcedure
.input(z.object({ invoiceId: z.uuid() }))
.output(InvoiceDto) // stops accidental field leaks
.query(async ({ ctx, input }) => {
const inv = await ctx.db.invoice.find({ id: input.invoiceId, orgId: input.orgId });
if (!inv) throw new TRPCError({ code: "NOT_FOUND" });
return inv;
}),
create: orgProcedure
.input(z.object({ customerId: z.uuid(), amountCents: z.int().positive() }))
.mutation(({ ctx, input }) => ctx.db.invoice.create({ ...input, createdBy: ctx.user.id })),
});
// server/routers/_app.ts
export const appRouter = router({ invoice: invoiceRouter, user: userRouter });
export type AppRouter = typeof appRouter; // the only thing the client imports
Next.js App Router adapter
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
const handler = (req: Request) =>
fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, createContext: () => createContext({ req }) });
export { handler as GET, handler as POST };
Other adapters: @trpc/server/adapters/express, /fastify, /standalone, and the fetch adapter
for Hono, Workers, Bun and Deno.
Client with TanStack React Query (v11)
// lib/trpc.ts
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "@/server/routers/_app";
export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>();
// providers.tsx
import { createTRPCClient, httpBatchLink } from "@trpc/client";
const trpcClient = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "/api/trpc", transformer: superjson })], // v11: transformer lives on the link
});
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>{children}</TRPCProvider>
</QueryClientProvider>
function Invoice({ orgId, invoiceId }: Props) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const invoice = useQuery(trpc.invoice.byId.queryOptions({ orgId, invoiceId }));
const create = useMutation(trpc.invoice.create.mutationOptions({
onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.invoice.pathKey() }),
}));
// ...
}
Server components and loaders can prefetch with createTRPCOptionsProxy({ router: appRouter, ctx, queryClient })
and hydrate, or call procedures directly through createCallerFactory(appRouter)(ctx).
Callers skip HTTP but still run middleware and validation.
Links
| Need | Link |
|---|---|
| Default | httpBatchLink (set maxURLLength so batched GETs do not exceed proxy limits, e.g. 2000) |
| Stream results as each procedure finishes | httpBatchStreamLink |
| Subscriptions | httpSubscriptionLink (SSE) with async generator procedures; WebSockets via wsLink only if needed |
| Uploads (FormData) or non-batched calls | splitLink routing those to httpLink |
Errors
TRPCError code | HTTP | Use |
|---|---|---|
BAD_REQUEST | 400 | Input failed validation (thrown automatically) |
UNAUTHORIZED | 401 | No or invalid session |
FORBIDDEN | 403 | Authenticated, not allowed |
NOT_FOUND | 404 | Missing or not visible to this caller |
CONFLICT | 409 | Duplicate or stale state |
PRECONDITION_FAILED | 412 | Version mismatch |
TOO_MANY_REQUESTS | 429 | Rate limited |
INTERNAL_SERVER_ERROR | 500 | Unexpected; message masked in production |
Throw with cause to keep the original error for logs. Use the onError option on the
adapter to log server errors with the procedure path. Never send DB error messages in
message for 5xx.
Workflow checklist
- Confirm tRPC fits the consumers; check versions and client integration style
- Add or reuse procedure builders for auth and tenancy; no auth checks inside handlers
- Validate every input; add
.output()where the return type comes from the DB - Wire adapter, links (batching limits) and transformer consistently on both sides
- Use
queryOptions/mutationOptionsand explicit invalidation bypathKey()orqueryKey() - Test procedures through
createCaller(fast) and one HTTP smoke test per router
Verification
const caller = createCallerFactory(appRouter)({ db: testDb, session: null });
await expect(caller.invoice.byId({ orgId, invoiceId })).rejects.toMatchObject({ code: "UNAUTHORIZED" });
- Run
tsc --noEmiton the client package: a renamed server field must break the client build. - Call a member of org A against org B’s invoice: expect
FORBIDDENorNOT_FOUND. - Check the Network tab or
curlthat batched requests go to one URL and stay under the URL limit.
Gotchas
- Mismatched transformers (superjson on server, none on client) produce
Datestrings or parse errors. - Importing server code (not just
type AppRouter) into the client bundles the server. Useimport type. - Huge routers slow TypeScript; split into sub-routers and avoid deeply inferred generic helpers.
- Mutations through GET are impossible by design, but queries with side effects are not: keep queries pure.
- Context creation runs for every batched call group; keep it cheap and lazy (session lookup only).
- Without
.output(), returning a full DB row leaks columns such as password hashes. - For React Query patterns independent of tRPC, use
tanstack-query.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "tRPC APIs"
short_description: "End-to-end types without leaking data or auth"
default_prompt: "$trpc-apis Add a protected tRPC router for invoices with org-level access checks" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.