API type-safe com tRPC e Claude Code
Crie uma API type-safe com Claude Code e tRPC: Next.js, Zod, permissões, cache invalidation e prompts de revisão.
O que tRPC e Claude Code resolvem juntos
tRPC permite que o router TypeScript do servidor seja também o contrato usado pelo cliente. Em vez de manter um arquivo OpenAPI separado ou um SDK manual, o front-end importa o tipo AppRouter e recebe autocomplete para inputs, outputs e erros. Para equipes que mudam telas e endpoints na mesma base de código, isso reduz muito a divergência entre API e UI.
Claude Code ajuda quando é usado com contexto do repositório, não apenas para gerar boilerplate. Ele pode ler a autenticação existente, a organização das pastas, a camada de dados, os padrões de React Query e as regras de nomes. Com isso, consegue sugerir routers, procedures, schemas Zod, autorização e cache invalidation de forma consistente. Mesmo assim, type safety não substitui regras de negócio: uma mutation pode compilar e ainda permitir acesso indevido se faltar checagem de permissão.
Este artigo usa Next.js App Router e uma API pequena de gestão de projetos. Para manter o exemplo copiável, a persistência é um Map em memória. Em produção, troque por Prisma, Drizzle, Supabase ou sua camada de dados real, e substitua a sessão de demonstração por autenticação de verdade.
Casos de uso
tRPC funciona melhor quando servidor e cliente vivem no mesmo sistema TypeScript. Para APIs públicas de terceiros, OpenAPI pode ser mais adequado. Para painéis internos, ferramentas operacionais, formulários e BFFs finos, tRPC costuma acelerar bastante.
| Caso de uso | Vantagem do tRPC | O que pedir ao Claude Code | Risco |
|---|---|---|---|
| CRUD administrativo | Inputs e outputs compartilhados para listar, criar, editar e excluir | Router, Zod schema, mutations e invalidation | Esconder botão sem autorização no servidor |
| Ferramentas internas | Mudanças rápidas sem manter SDK separado | Procedures a partir de modelos existentes | Context grande demais |
| Envio de formulários | Validação runtime de email, tamanho e enum | Erros claros e proteção contra duplo envio | Confiar só em TypeScript |
| BFF fino | Resposta no formato exato da tela | Mapeamento de APIs externas | Cache e refetch indefinidos |
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
Instalação e estrutura
Instale os pacotes principais. Se o projeto já usa React Query ou Zod, alinhe versões antes de gerar código.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
Esta é a estrutura usada no exemplo. Código de servidor fica em src/server, a rota HTTP fica no App Router e o provider React fica em 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
Ao pedir ajuda ao Claude Code, declare a fronteira: arquivos de servidor não importam client components, client components não acessam o store diretamente e os tipos compartilhados vêm do router tRPC, não de DTOs paralelos.
Context e procedures
O context leva informações por request para cada procedure. Mantenha pequeno: sessão, equipe, papel e acesso à base de dados geralmente bastam. O exemplo abaixo usa headers apenas para demonstração 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 e adminProcedure cria um ponto claro de revisão. Se uma mutation destrutiva usar o procedure errado, o problema aparece logo no diff.
Router com validação Zod
TypeScript valida o código no build, mas não valida JSON recebido em runtime. Zod deve ficar na borda do procedure, antes da operação de negócio.
// 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;
}),
});
A checagem de teamId é essencial. Um UUID válido não prova que o usuário atual pode alterar aquele registro. Type safety não cobre essa regra de negócio automaticamente.
App Router e cliente React
Monte o router principal e exponha o endpoint pelo 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>
);
}
Depois da mutation, utils.project.list.invalidate() força a lista a buscar dados novos. Sem essa regra, o usuário pode criar um item e continuar vendo dados antigos.
Prompt de revisão para Claude Code
Revise esta implementação de Next.js App Router + tRPC.
Verifique:
1. Uso correto de publicProcedure, protectedProcedure e adminProcedure.
2. Zod runtime validation para todo input externo.
3. Checagem de teamId e userId antes de ler ou escrever.
4. Invalidation das queries afetadas por mutations.
5. Routers separados por domínio e sem arquivos enormes.
6. Context apenas com dependências por request.
7. Nenhum campo secreto retornado ao cliente.
Retorne severidade, arquivo, problema, correção e testes recomendados em tabela.
Esse prompt pode entrar no CLAUDE.md ou no template de pull request. Em mudanças de permissão, peça primeiro o diff e a análise de risco antes de aplicar automaticamente.
Armadilhas comuns
A primeira armadilha é context inchado. Colocar tudo em ctx parece prático, mas dificulta testes. A segunda é autorização só na UI: esconder botão não protege endpoint. A terceira é confiar em TypeScript sem Zod; requests reais precisam de validação runtime. A quarta é não dividir routers; um arquivo único vira um gargalo de revisão. A quinta é cache invalidation mal definida; uma mutation pode afetar lista, detalhe, contador e dashboard.
Referências e próximos passos
Use a documentação oficial de tRPC para routers, procedures e React Query integration. Para validação runtime, consulte Zod. Para o endpoint no App Router, veja Next.js Route Handlers.
Para continuar, leia o guia de validação Zod com Claude Code e as dicas de TypeScript com Claude Code. Se sua equipe quer revisar um painel interno real, a consultoria e treinamento de Claude Code pode cobrir desenho de routers, permissões, prompts e testes.
Resultado ao testar
Em um protótipo de painel administrativo, o maior ganho veio de criar protectedProcedure e adminProcedure antes do CRUD. Claude Code conseguiu apontar mutations que precisavam de permissão de administrador. Deixar os schemas Zod perto das procedures também facilitou revisar mudanças de formulário. O ponto fraco apareceu quando o context cresceu demais; por isso a versão final manteve o context pequeno e moveu a lógica de negócio para funções separadas.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.