API type-safe con tRPC y Claude Code
Crea una API type-safe con Claude Code y tRPC: Next.js, Zod, permisos, cache invalidation y prompts de revisión.
Qué problema resuelven tRPC y Claude Code
tRPC permite que el router escrito en TypeScript en el servidor sea también el contrato que usa el cliente. En vez de mantener un archivo OpenAPI separado o un SDK escrito a mano, el front-end importa el tipo AppRouter y obtiene autocompletado para inputs, outputs y errores. En un equipo que cambia pantallas y endpoints con frecuencia, esto reduce mucho la deriva entre API y UI.
Claude Code aporta valor cuando no se usa solo para generar boilerplate. Puede leer la estructura real del repositorio, el sistema de autenticación, la capa de datos, los patrones de React Query y las reglas del equipo. Con ese contexto puede proponer routers, procedures, validación con Zod, autorización y cache invalidation de forma consistente. Aun así, type safety no sustituye las reglas de negocio: una mutation puede compilar y seguir permitiendo que un usuario modifique datos de otro equipo. Por eso el flujo correcto es generar, revisar de forma crítica y probar los bordes de seguridad.
El ejemplo de este artículo usa Next.js App Router y una API pequeña de gestión de proyectos. Para que el código sea fácil de copiar, la persistencia es un Map en memoria. En una aplicación real deberías cambiarlo por Prisma, Drizzle, Supabase u otra capa de datos, y sustituir la sesión de demostración por tu autenticación real.
Casos de uso prácticos
tRPC funciona especialmente bien cuando el servidor y el cliente viven en el mismo sistema TypeScript. Si necesitas una API pública para terceros durante años, quizá OpenAPI sea mejor. Para herramientas internas, paneles de administración y BFF ligeros, tRPC suele acelerar el trabajo.
| Caso de uso | Ventaja de tRPC | Qué pedir a Claude Code | Riesgo principal |
|---|---|---|---|
| CRUD de administración | Inputs y outputs compartidos para listar, crear, editar y borrar | Router, Zod schema, mutation e invalidation | Ocultar botones sin validar permisos en servidor |
| Herramientas internas | Cambios de flujo rápidos sin mantener un SDK aparte | Procedures a partir de modelos existentes | Context demasiado grande |
| Formularios | Validación runtime de email, longitud y enum | Mensajes de error y prevención de doble envío | Confiar solo en tipos estáticos |
| BFF delgado | Respuestas adaptadas a la pantalla | Mapeo de APIs externas | Reglas de cache poco claras |
flowchart LR
UI["React component"]
Client["tRPC React client"]
Router["AppRouter"]
Procedure["protected/admin procedure"]
Zod["Zod validation"]
Store["DB or store"]
Review["Claude Code review"]
UI --> Client --> Router --> Procedure --> Zod --> Store
Router --> Review
Review --> Procedure
Instalación y estructura
Instala los paquetes base. Si tu proyecto ya usa React Query o Zod, revisa versiones antes de generar código para evitar errores de tipos difíciles de diagnosticar.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
Esta guía usa una estructura simple. El código de servidor queda bajo src/server, el adaptador HTTP vive en api/trpc, y el provider de React queda en src/trpc.
src/
app/api/trpc/[trpc]/route.ts
app/projects/project-list.tsx
server/trpc.ts
server/routers/_app.ts
server/routers/project.ts
trpc/client.tsx
Cuando trabajes con Claude Code, dale esta frontera explícita: los archivos de servidor no deben importar componentes cliente, los componentes cliente no deben tocar el store directamente y los tipos compartidos deben venir del router, no de un DTO paralelo mantenido a mano.
Context y procedures con permisos
El context contiene datos por request que llegan a cada procedure. Debe ser pequeño: sesión, rol, identificador de equipo y, en un proyecto real, el acceso a base de datos. El ejemplo usa headers como demo local.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
type Role = "admin" | "member";
type Session = { userId: string; teamId: string; role: Role };
export type Context = { session: Session | null };
export async function createContext({
headers,
}: {
headers: Headers;
}): Promise<Context> {
const roleHeader = headers.get("x-user-role");
const role: Role =
roleHeader === "admin"
? "admin"
: roleHeader === "member"
? "member"
: process.env.NODE_ENV === "production"
? "member"
: "admin";
return {
session: {
userId: headers.get("x-user-id") ?? "demo-user",
teamId: headers.get("x-team-id") ?? "demo-team",
role,
},
};
}
const t = initTRPC.context<Context>().create({ transformer: superjson });
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required." });
}
return next({ ctx: { session: ctx.session } });
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role is required." });
}
return next();
});
Separar protectedProcedure y adminProcedure crea una señal clara para revisión. Si una operación destructiva usa el procedure equivocado, el problema se ve en la primera lectura del diff.
Router con Zod runtime validation
TypeScript no valida el JSON que llega desde un navegador, un script o un cliente comprometido. Zod se usa en el borde del procedure para comprobar valores en runtime antes de ejecutar reglas de negocio.
// src/server/routers/project.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
type ProjectStatus = "todo" | "doing" | "done";
type Project = {
id: string;
teamId: string;
title: string;
ownerEmail: string;
status: ProjectStatus;
createdAt: string;
};
const projects = new Map<string, Project>();
const projectStatus = z.enum(["todo", "doing", "done"]);
const listProjectsInput = z
.object({
status: projectStatus.optional(),
query: z.string().trim().max(60).optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.default({ limit: 20 });
const createProjectInput = z.object({
title: z.string().trim().min(2).max(80),
ownerEmail: z.string().email(),
});
export const projectRouter = createTRPCRouter({
list: protectedProcedure.input(listProjectsInput).query(({ ctx, input }) => {
return [...projects.values()]
.filter((project) => project.teamId === ctx.session.teamId)
.filter((project) => !input.status || project.status === input.status)
.filter((project) => !input.query || project.title.includes(input.query))
.slice(0, input.limit);
}),
create: adminProcedure.input(createProjectInput).mutation(({ ctx, input }) => {
const project: Project = {
id: crypto.randomUUID(),
teamId: ctx.session.teamId,
title: input.title,
ownerEmail: input.ownerEmail,
status: "todo",
createdAt: new Date().toISOString(),
};
projects.set(project.id, project);
return project;
}),
updateStatus: protectedProcedure
.input(z.object({ id: z.string().uuid(), status: projectStatus }))
.mutation(({ ctx, input }) => {
const project = projects.get(input.id);
if (!project || project.teamId !== ctx.session.teamId) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const nextProject = { ...project, status: input.status };
projects.set(input.id, nextProject);
return nextProject;
}),
});
La validación de teamId es tan importante como el schema. Un UUID válido no demuestra que el usuario tenga permiso para tocar ese registro. Esta comprobación evita filtraciones entre equipos.
App Router y cliente React
Compón el router principal y expón el endpoint con Route Handler.
// src/server/routers/_app.ts
import { createTRPCRouter } from "../trpc";
import { projectRouter } from "./project";
export const appRouter = createTRPCRouter({
project: projectRouter,
});
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };
// src/trpc/client.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState, type ReactNode } from "react";
import superjson from "superjson";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [httpBatchLink({ url: "/api/trpc" })],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/projects/project-list.tsx
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const utils = trpc.useUtils();
const [title, setTitle] = useState("");
const projects = trpc.project.list.useQuery({ limit: 20 });
const createProject = trpc.project.create.useMutation({
onSuccess: async () => {
setTitle("");
await utils.project.list.invalidate();
},
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
createProject.mutate({ title, ownerEmail: "owner@example.com" });
}}
>
<input value={title} onChange={(event) => setTitle(event.target.value)} />
<button type="submit">Add</button>
<pre>{JSON.stringify(projects.data, null, 2)}</pre>
</form>
);
}
Después de una mutation se invalida project.list. Sin esta decisión explícita, el usuario puede crear datos y seguir viendo una lista antigua, lo que parece un fallo intermitente.
Prompt de revisión para Claude Code
Revisa esta implementación de Next.js App Router + tRPC.
Comprueba:
1. Uso correcto de publicProcedure, protectedProcedure y adminProcedure.
2. Zod runtime validation para todo input externo.
3. Verificación de teamId y userId antes de leer o escribir datos.
4. Cache invalidation después de mutations.
5. Routers separados por dominio y sin archivos gigantes.
6. Context con solo dependencias por request.
7. Ningún campo secreto vuelve al cliente.
Devuelve severidad, archivo, problema, solución y tests recomendados en una tabla.
Este prompt funciona bien en CLAUDE.md o en una plantilla de pull request. En cambios de permisos, pide primero el diff y la explicación de riesgo; no apliques automáticamente cambios sensibles sin revisión humana.
Errores y trampas comunes
La primera trampa es un context inflado. Agregar base de datos, clientes externos, flags, logger y estado de UI parece cómodo, pero hace que cada procedure dependa de todo. La segunda es autorización solo en la UI: esconder un botón no protege el endpoint. La tercera es confiar en TypeScript sin Zod; los tipos no validan requests reales. La cuarta es no dividir routers; un único archivo se vuelve imposible de revisar. La quinta es no diseñar cache invalidation: una mutation puede afectar lista, detalle, contador y dashboard al mismo tiempo.
Referencias, enlaces internos y CTA
Consulta la documentación oficial de tRPC sobre routers, procedures y React Query integration. Para validación runtime usa Zod y para el endpoint revisa Next.js Route Handlers.
Para profundizar, lee la guía de validación Zod con Claude Code y los consejos de TypeScript con Claude Code. Si quieres revisar un panel interno real, ClaudeCodeLab ofrece consultoría y formación de Claude Code para ordenar routers, permisos, prompts y pruebas.
Resultado al probarlo
Al aplicar este patrón en un prototipo de panel de administración, el mayor beneficio fue crear primero protectedProcedure y adminProcedure. Claude Code pudo señalar con claridad qué mutations requerían permisos de administrador. Mantener los schemas de Zod cerca de cada procedure también facilitó revisar cambios de formularios. La parte menos cómoda fue el testing cuando el context creció demasiado, así que la versión final mantuvo el context pequeño y movió la lógica de negocio a funciones separadas.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.