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: "[email protected]" });
}}
>
<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.
Artigos relacionados
Validação Zod com Claude Code em TypeScript
Use Claude Code e Zod para validar formulários, APIs, variáveis de ambiente, webhooks e testes.
Claude Code e TypeScript: dicas práticas para ganhar velocidade com segurança
Use strict, Zod, Union, genéricos, satisfies e testes de tipos para melhorar TypeScript com Claude Code.
Tipos utilitários do TypeScript com Claude Code: guia prático
Aprenda Pick, Omit, Partial, Record, ReturnType e Awaited com exemplos executáveis para Claude Code.
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.