Construir Uma API Type-Safe Com o Claude Code (oRPC + Zod)
Como construir uma camada de API totalmente type-safe em Next.js 16 com oRPC e Zod, usando o Claude Code, para que as mudanças de esquema partam a build em vez da produção.
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
A versão de uma API type-safe que interessa mesmo não é um diagrama, é isto: renomeias um campo no teu esquema Zod, e o componente cliente que o lê fica vermelho no teu editor antes sequer de teres guardado o ficheiro. Nenhuma coleção do Postman para atualizar. Nenhum 500 em runtime na produção três dias depois. O erro aparece no exato momento em que partiste algo, no exato ficheiro que precisas de corrigir.
É isso que o oRPC mais o Zod te dão numa app Next.js 16, e o Claude Code é uma forma rápida de o ligar corretamente à primeira.
O bug que esta configuração evita
O modo de falha comum numa rota de API típica do Next.js é este: defines um route handler que devolve { id, title, done }. Noutro sítio qualquer, um componente desestrutura task.completed porque alguém renomeou o campo no servidor há seis semanas e ninguém atualizou o cliente. O TypeScript não o consegue apanhar, porque o fetch() devolve any (ou um tipo que escreveste à mão e em que agora confias às cegas). O bug vai para produção. Descobre-lo por um alerta do Sentry, não por um compilador.
"Type-safe da base de dados ao frontend" significa que há exatamente uma fonte da verdade para o formato dos teus dados, um esquema Zod, e tanto o teu handler no servidor como a tua chamada no cliente são tipados a partir desse mesmo esquema. Muda o esquema, e todos os sítios por onde esse formato passa acendem-se com um erro de tipos até os corrigires.
Instalar o oRPC e o Zod
O oRPC vem em alguns pacotes pequenos: um pacote de servidor para definir procedimentos e montá-los como um handler, e um pacote de cliente para os chamar com inferência completa. Instala ambos ao lado do Zod.
npm install @orpc/server @orpc/client zodNada mais é preciso. Nenhum passo de geração de código, nenhuma CLI para correr depois de cada mudança de esquema. Os tipos vêm de o TypeScript inferir através do esquema Zod, não de um artefacto de build que tens de te lembrar de regenerar.
Definir o esquema
Começa pelo formato da coisa que a tua API devolve. Isto é uma pequena API de lista de tarefas, um esquema de objeto Zod para uma tarefa, e um esquema derivado para o que um pedido de "criar" aceita (apenas o título, já que o id e o done são definidos no servidor).
// lib/schemas/task.ts
import * as z from "zod";
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
done: z.boolean(),
});
export const createTaskInput = taskSchema.pick({ title: true });
export type Task = z.infer<typeof taskSchema>;O z.infer é que faz o trabalho de verdade aqui. O Task não é um tipo que mantenhas à mão, é derivado do esquema, por isso nunca pode dessincronizar-se do validador em runtime. Se adicionares um campo ao taskSchema, o Task apanha-o automaticamente.
Para o tutorial, a "base de dados" é um array em memória. Troca-a por queries reais de Supabase ou Postgres numa app de produção, o resto da configuração não muda.
// lib/db/tasks.ts
import { randomUUID } from "crypto";
import type { Task } from "@/lib/schemas/task";
const tasks: Task[] = [
{ id: randomUUID(), title: "Wire up the API layer", done: false },
];
export async function listTasks(): Promise<Task[]> {
return tasks;
}
export async function createTask(title: string): Promise<Task> {
const task: Task = { id: randomUUID(), title, done: false };
tasks.push(task);
return task;
}Definir um procedimento oRPC
Um procedimento no oRPC é construído com os, um builder encadeável do @orpc/server. Anexas um esquema de input, um esquema de output, e uma função handler. Ambas as chamadas de esquema são opcionais, mas anexar as duas é o que te dá validação à entrada e um formato tipado e verificado à saída.
// lib/orpc/router.ts
import * as z from "zod";
import { os } from "@orpc/server";
import { taskSchema, createTaskInput } from "@/lib/schemas/task";
import { listTasks, createTask } from "@/lib/db/tasks";
const list = os.output(z.array(taskSchema)).handler(async () => {
return listTasks();
});
const create = os
.input(createTaskInput)
.output(taskSchema)
.handler(async ({ input }) => {
return createTask(input.title);
});
export const router = {
tasks: {
list,
create,
},
};
export type AppRouter = typeof router;Duas coisas que vale a pena reparar. Primeiro, o input dentro do handler create já está tipado como { title: string }, sem cast, sem interface manual. Segundo, se o createTask devolvesse algo que não corresponde ao taskSchema, o TypeScript assinala-o logo ali no handler, antes de o código sequer correr. O esquema de output é um contrato sobre o código do teu próprio servidor, não só sobre o cliente.
Montar o router como um Route Handler
O oRPC expõe um handler RPC que fala fetch Request/Response puro, o que mapeia diretamente num Route Handler do Next.js 16. Monta-o num segmento catch-all para que todos os procedimentos do router fiquem acessíveis sob um único prefixo.
// app/rpc/[[...rest]]/route.ts
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/lib/orpc/router";
const handler = new RPCHandler(router);
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: "/rpc",
context: {},
});
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;Todos os verbos HTTP apontam para a mesma função handleRequest porque o handler RPC descobre qual o procedimento a chamar a partir do próprio pedido. Não escreves um ficheiro separado por endpoint, e não mapeias URLs para funções à mão. Adicionar um novo procedimento ao router em lib/orpc/router.ts é o diff inteiro.
Chamá-lo a partir de um Server Component
Dentro de um Server Component, não há razão para fazer uma ida e volta por HTTP para chamar o teu próprio servidor. O cliente do lado do servidor do oRPC (createRouterClient) chama a função handler do procedimento diretamente, no mesmo processo, mantendo exatamente a mesma interface tipada do cliente de rede.
// lib/orpc/server-client.ts
import "server-only";
import { createRouterClient } from "@orpc/server";
import { router } from "@/lib/orpc/router";
export const orpc = createRouterClient(router, {
context: {},
});O import server-only é uma proteção, lança um erro de build se este ficheiro for alguma vez puxado para código de cliente por engano. Usa-o numa página:
// app/tasks/page.tsx
import { orpc } from "@/lib/orpc/server-client";
export default async function TasksPage() {
const tasks = await orpc.tasks.list();
return (
<main className="max-w-lg mx-auto py-12">
<h1 className="text-2xl font-bold mb-6">Tasks</h1>
<ul className="space-y-2">
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
</main>
);
}O tasks é Task[], inferido todo o caminho a partir do taskSchema. Nenhum tipo de retorno manual no TasksPage, nenhum any a entrar sorrateiramente de uma chamada fetch sem tipo.
Chamá-lo a partir de um Client Component
O browser não consegue chamar as tuas funções de servidor no mesmo processo, por isso o cliente do lado do cliente vai pela rede, usando o link RPC apontado para a rota que montaste antes. A interface é idêntica à do cliente do lado do servidor, os mesmos nomes de métodos, os mesmos formatos de argumentos, os mesmos tipos de retorno inferidos.
// lib/orpc/client.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import type { AppRouter } from "@/lib/orpc/router";
const link = new RPCLink({
url: "/rpc",
});
export const orpc: RouterClient<AppRouter> = createORPCClient(link);Usa-o num formulário. O title é uma string simples do useState, e o valor de retorno de orpc.tasks.create(...) é uma Task totalmente tipada, não uma Response a que tens de fazer .json() e cast.
// components/new-task-form.tsx
"use client";
import { useState } from "react";
import { orpc } from "@/lib/orpc/client";
export function NewTaskForm() {
const [title, setTitle] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
const task = await orpc.tasks.create({ title });
setPending(false);
setTitle("");
console.log("created task", task.id, task.done);
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="border rounded px-3 py-2 flex-1"
placeholder="New task"
/>
<button
type="submit"
disabled={pending}
className="bg-black text-white rounded px-4 py-2"
>
Add
</button>
</form>
);
}Se tentasses passar { title, done: false } para orpc.tasks.create, o TypeScript rejeitava-o antes de correres seja o que for, porque o createTaskInput só aceita title. É o esquema de input a fazer o seu trabalho do lado do cliente, não só do lado do servidor.
Ver o compilador apanhar uma mudança que parte tudo
Esta é a parte que vale mesmo a pena experimentar. Abre o lib/schemas/task.ts e renomeia done para completed:
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
completed: z.boolean(),
});Guarda o ficheiro. O NewTaskForm mostra agora um erro de compilação em task.done: Property 'done' does not exist on type '{ id: string; title: string; completed: boolean }'. O lib/db/tasks.ts também dá erro, já que o createTask continua a construir um objeto com uma chave done. Nada corre até cada um deles estar corrigido.
É esse o argumento inteiro. O esquema mudou uma vez, num ficheiro, e o TypeScript encontrou todos os sítios a jusante que assumiam o formato antigo. Nenhum grep por .done em todo o código, nenhum erro em runtime dias depois quando um utilizador real bate no caminho de código desatualizado.
Usar o Claude Code para ligar isto
Pede ao Claude Code para adicionar um novo procedimento e ele segue o padrão assim que o teu CLAUDE.md declara a convenção de forma clara:
## API Layer
- oRPC procedures live in lib/orpc/router.ts
- Every procedure gets both .input() and .output() with a Zod schema
- Zod schemas for API-facing shapes live in lib/schemas/
- Route handler is mounted at app/rpc/[[...rest]]/route.ts, don't create new route files per endpointCom isso no sítio, "add a procedure to mark a task done" produz uma nova entrada no mesmo objeto router, uma atualização de esquema se for preciso, e um handler que corresponde ao estilo existente, em vez de uma rota de API ad-hoc nova com os seus próprios tipos escritos à mão.
O Code Kit de $29 assenta exatamente neste padrão. É uma estrutura por cima do Claude Code, não uma framework separada, e a camada de API que ele gera é oRPC com Zod por omissão, type-safe desde as tuas queries à base de dados até ao componente que renderiza os dados. Continuas a precisar de um plano Anthropic pago para correr o próprio Claude Code. O que a estrutura acrescenta é a convenção já ligada, para que cada nova funcionalidade que ela constrói receba esta mesma garantia sem teres de a pedir de cada vez.
Posted by @speedy_devv
Quer o framework por trás destes projetos?
Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.
Adicionar Email Transacional Com o Claude Code (Resend + React Email)
Como construir emails de boas-vindas, recibos e reposição de password numa app Next.js 16 usando o Resend e o React Email, com o Claude Code a escrever os templates e a lógica de envio.
File Uploads
Build secure user file and image uploads end to end with Claude Code: private Supabase Storage buckets, per-user RLS, signed URLs, and an upload UI wired through a type-safe oRPC API.

