Eine typsichere API mit Claude Code bauen (oRPC + Zod)
So baust du eine vollständig typsichere API-Schicht in Next.js 16 mit oRPC und Zod, mit Claude Code, sodass Schema-Änderungen den Build brechen statt der Produktion.
Sie möchten das Framework hinter diesen Projekten?
Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.
Die Version einer typsicheren API, die wirklich zählt, ist kein Diagramm, sie ist das hier: Du benennst ein Feld in deinem Zod-Schema um, und die Client-Komponente, die es liest, wird in deinem Editor rot, bevor du die Datei überhaupt gespeichert hast. Keine Postman-Collection zu aktualisieren. Kein Runtime-500 in Produktion drei Tage später. Der Fehler taucht genau in dem Moment auf, in dem du etwas kaputt gemacht hast, in genau der Datei, die du fixen musst.
Genau das gibt dir oRPC plus Zod in einer Next.js-16-App, und Claude Code ist ein schneller Weg, es im ersten Anlauf korrekt zu verkabeln.
Der Bug, den dieses Setup verhindert
Der übliche Fehlermodus in einer typischen Next.js-API-Route sieht so aus: Du definierst einen Route Handler, der { id, title, done } zurückgibt. Irgendwo anders destrukturiert eine Komponente task.completed, weil jemand das Feld vor sechs Wochen auf dem Server umbenannt hat und niemand den Client aktualisiert hat. TypeScript kann es nicht abfangen, weil fetch() any zurückgibt (oder einen Typ, den du von Hand geschrieben hast und dem du jetzt blind vertraust). Der Bug geht live. Du erfährst es aus einem Sentry-Alert, nicht von einem Compiler.
"Typsicher von der Datenbank bis zum Frontend" heißt, es gibt genau eine Source of Truth für die Form deiner Daten, ein Zod-Schema, und sowohl dein Server-Handler als auch dein Client-Call sind von genau diesem Schema getypt. Änder das Schema, und jede Stelle, durch die diese Form fließt, leuchtet mit einem Type-Error auf, bis du sie fixst.
oRPC und Zod installieren
oRPC kommt als ein paar kleine Pakete: ein Server-Paket, um Procedures zu definieren und sie als Handler zu mounten, und ein Client-Paket, um sie mit voller Inferenz aufzurufen. Installier beide neben Zod.
npm install @orpc/server @orpc/client zodMehr braucht es nicht. Kein Code-Generation-Schritt, keine CLI, die man nach jeder Schema-Änderung laufen lassen muss. Die Typen kommen daher, dass TypeScript durch das Zod-Schema inferiert, nicht aus einem Build-Artefakt, an dessen Neugenerierung du denken musst.
Das Schema definieren
Starte mit der Form des Dings, das deine API zurückgibt. Das ist eine kleine Task-List-API, ein Zod-Objekt-Schema für eine Task und ein abgeleitetes Schema dafür, was ein "Create"-Request akzeptiert (nur der Titel, da id und done serverseitig gesetzt werden).
// 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>;z.infer macht hier die eigentliche Arbeit. Task ist kein Typ, den du von Hand pflegst, er ist vom Schema abgeleitet, kann also nie mit dem Runtime-Validator aus dem Takt geraten. Wenn du taskSchema ein Feld hinzufügst, nimmt Task es automatisch auf.
Fürs Tutorial ist die "Datenbank" ein In-Memory-Array. Tausch das in einer Produktions-App gegen echte Supabase- oder Postgres-Queries, der Rest des Setups ändert sich nicht.
// 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;
}Eine oRPC-Procedure definieren
Eine Procedure in oRPC wird mit os gebaut, einem verkettbaren Builder aus @orpc/server. Du hängst ein Input-Schema, ein Output-Schema und eine Handler-Funktion an. Beide Schema-Calls sind optional, aber beide anzuhängen ist das, was dir Validierung auf dem Weg rein und eine getypte, geprüfte Form auf dem Weg raus verschafft.
// 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;Zwei Dinge, die auffallen. Erstens ist input innerhalb des create-Handlers schon als { title: string } getypt, kein Cast, kein manuelles Interface. Zweitens, wenn createTask etwas zurückgäbe, das nicht zu taskSchema passt, markiert TypeScript es direkt dort im Handler, bevor der Code je läuft. Das Output-Schema ist ein Vertrag über deinen eigenen Server-Code, nicht nur über den Client.
Den Router als Route Handler mounten
oRPC stellt einen RPC-Handler bereit, der schlichtes fetch Request/Response spricht, was direkt auf einen Next.js-16-Route-Handler abbildet. Mounte ihn auf einem Catch-all-Segment, damit jede Procedure im Router unter einem Präfix erreichbar ist.
// 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;Jedes HTTP-Verb zeigt auf dieselbe handleRequest-Funktion, weil der RPC-Handler aus dem Request selbst herausfindet, welche Procedure aufzurufen ist. Du schreibst keine separate Datei pro Endpoint, und du mappst URLs nicht von Hand auf Funktionen. Eine neue Procedure zum router in lib/orpc/router.ts hinzuzufügen ist der ganze Diff.
Aus einer Server Component aufrufen
Innerhalb einer Server Component gibt es keinen Grund, für einen Call an deinen eigenen Server über HTTP hin und her zu gehen. Der serverseitige Client von oRPC (createRouterClient) ruft die Handler-Funktion der Procedure direkt auf, in-process, bei genau demselben getypten Interface wie der Netzwerk-Client.
// 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: {},
});Der server-only-Import ist ein Schutzgeländer, es wirft einen Build-Error, falls diese Datei je versehentlich in Client-Code gezogen wird. Nutz es in einer Seite:
// 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>
);
}tasks ist Task[], den ganzen Weg von taskSchema inferiert. Kein manueller Return-Typ auf TasksPage, kein any, das sich aus einem ungetypten fetch-Call einschleicht.
Aus einer Client Component aufrufen
Der Browser kann deine Server-Funktionen nicht in-process aufrufen, also geht der clientseitige Client über die Leitung, über den RPC-Link, der auf die vorhin gemountete Route zeigt. Das Interface ist identisch mit dem serverseitigen Client, dieselben Methodennamen, dieselben Argument-Formen, dieselben inferierten Return-Typen.
// 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);Nutz es in einem Formular. title ist ein schlichter String aus useState, und der Rückgabewert von orpc.tasks.create(...) ist eine vollständig getypte Task, keine Response, die du .json()-en und casten musst.
// 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>
);
}Wenn du versuchst, { title, done: false } in orpc.tasks.create zu übergeben, würde TypeScript es ablehnen, bevor du irgendetwas ausführst, weil createTaskInput nur title akzeptiert. Das ist das Input-Schema, das auf der Client-Seite seinen Job macht, nicht nur auf der Server-Seite.
Dem Compiler beim Abfangen einer Breaking Change zusehen
Das ist der Teil, den es sich lohnt, tatsächlich auszuprobieren. Öffne lib/schemas/task.ts und benenne done in completed um:
export const taskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
completed: z.boolean(),
});Speicher die Datei. NewTaskForm zeigt jetzt einen Compile-Error bei task.done: Property 'done' does not exist on type '{ id: string; title: string; completed: boolean }'. lib/db/tasks.ts fehlert auch, da createTask immer noch ein Objekt mit einem done-Key baut. Nichts läuft, bis jeder einzelne davon gefixt ist.
Das ist der ganze Pitch. Das Schema hat sich einmal geändert, in einer Datei, und TypeScript hat jede Stelle stromabwärts gefunden, die die alte Form annahm. Kein Grep nach .done über die Codebase, kein Runtime-Error Tage später, wenn ein echter Nutzer auf den veralteten Code-Pfad trifft.
Claude Code nutzen, um das zu verkabeln
Lass Claude Code eine neue Procedure hinzufügen, und es folgt dem Pattern, sobald deine CLAUDE.md die Konvention klar benennt:
## 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 endpointDamit das steht, produziert "add a procedure to mark a task done" einen neuen Eintrag im selben Router-Objekt, ein Schema-Update, falls nötig, und einen Handler, der zum bestehenden Stil passt, statt einer frischen Ad-hoc-API-Route mit ihren eigenen handgeschriebenen Typen.
Das $29 Code Kit baut auf genau diesem Pattern auf. Es ist ein Gerüst auf Claude Code, kein separates Framework, und die API-Schicht, die es generiert, ist standardmäßig oRPC mit Zod, typsicher von deinen Datenbank-Queries bis zur Komponente, die die Daten rendert. Du brauchst weiterhin einen bezahlten Anthropic-Plan, um Claude Code selbst zu betreiben. Was das Gerüst hinzufügt, ist die Konvention schon verkabelt, sodass jedes neue Feature, das es baut, dieselbe Garantie bekommt, ohne dass du jedes Mal danach fragen musst.
Posted by @speedy_devv
Sie möchten das Framework hinter diesen Projekten?
Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.
Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)
So baust du Willkommens-, Beleg- und Passwort-Reset-E-Mails in einer Next.js-16-App mit Resend und React Email, wobei Claude Code die Templates und die Send-Logik schreibt.
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.

