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.

  • Backend & APIs
  • $trpc-apis
  • 205 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- trpc-apis

Installs to ~/.agents/skills/trpc-apis/ for every project on this machine.

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

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.

~/.agents/skills/trpc-apis/SKILL.md

--- name: trpc-apis description: Builds type-safe TypeScript APIs with tRPC v11 - initTRPC context, routers and procedures, input and output validation with Zod or any Standard… license: MIT ---

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

SituationChoice
TypeScript frontend and backend in one repo or monorepotRPC
Public API, third-party consumers, mobile apps in other languagesREST with OpenAPI (openapi-first) or GraphQL
Mixed: internal app plus a few public endpointstRPC 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

  1. Read AGENTS.md; check versions: rg -n "\"@trpc/|\"@tanstack/react-query\"|\"zod\"|\"superjson\"" package.json */package.json.
  2. 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.
  3. Find the initTRPC file 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.

NeedLink
DefaulthttpBatchLink (set maxURLLength so batched GETs do not exceed proxy limits, e.g. 2000)
Stream results as each procedure finisheshttpBatchStreamLink
SubscriptionshttpSubscriptionLink (SSE) with async generator procedures; WebSockets via wsLink only if needed
Uploads (FormData) or non-batched callssplitLink routing those to httpLink

Errors

TRPCError codeHTTPUse
BAD_REQUEST400Input failed validation (thrown automatically)
UNAUTHORIZED401No or invalid session
FORBIDDEN403Authenticated, not allowed
NOT_FOUND404Missing or not visible to this caller
CONFLICT409Duplicate or stale state
PRECONDITION_FAILED412Version mismatch
TOO_MANY_REQUESTS429Rate limited
INTERNAL_SERVER_ERROR500Unexpected; 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/mutationOptions and explicit invalidation by pathKey() or queryKey()
  • 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 --noEmit on the client package: a renamed server field must break the client build.
  • Call a member of org A against org B’s invoice: expect FORBIDDEN or NOT_FOUND.
  • Check the Network tab or curl that batched requests go to one URL and stay under the URL limit.

Gotchas

  • Mismatched transformers (superjson on server, none on client) produce Date strings or parse errors.
  • Importing server code (not just type AppRouter) into the client bundles the server. Use import 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.yamlgenerated from this page

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.

esc