Produktionsreife API-Entwicklung mit Claude Code: OpenAPI, Next.js, Zod und CI
Baue produktionsreife APIs mit Claude Code: OpenAPI-Vertrag, Next.js Route Handler, Zod, Tests und CI.
Der gefährlichste Prompt für API-Entwicklung mit Claude Code lautet sinngemäß: “Baue mir einen Endpoint, der funktioniert.” Für eine Demo reicht das oft. Für eine produktionsreife API fehlen dann aber Vertrag, Validierung, Authentifizierung, Idempotenz, Rate Limiting, ein stabiles Fehlerformat, Logs, Tests und CI.
Dieser Leitfaden nutzt Claude Code als Partner für produktive Backend-Arbeit, nicht nur als Codegenerator. Der Ablauf ist contract-first: zuerst wird die API mit OpenAPI beschrieben, dann mit einem Next.js Route Handler implementiert, mit Zod validiert und über Vitest sowie GitHub Actions an das Team übergeben.
Masa hat dieses Muster an einer kleinen Bestell-API ausprobiert. Beim ersten Prompt “erstelle POST /orders” änderten sich Fehlerformat und Retry-Verhalten von Version zu Version. Als OpenAPI-Vertrag, Auth-Grenze, Idempotenzregel, Fehler-Envelope und CI-Befehle Teil des Prompts wurden, war die Review deutlich konkreter.
Erst den OpenAPI-Vertrag schreiben
OpenAPI beschreibt Pfade, Methoden, Request Bodies, Responses und Authentifizierung einer HTTP-API in maschinenlesbarer Form. Einfach gesagt: Es ist das Versprechen der API, bevor Code geschrieben wird. Die offizielle Referenz ist die OpenAPI Specification.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/api/orders:
post:
operationId: createOrder
summary: Create an order
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
minLength: 8
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrder"
responses:
"201":
description: Order created
"400":
description: Invalid request
"401":
description: Missing or invalid token
"409":
description: Idempotency key reused with another payload
"429":
description: Rate limit exceeded
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
CreateOrder:
type: object
required: [customerId, items, currency]
properties:
customerId:
type: string
minLength: 3
currency:
type: string
enum: [JPY, USD, EUR]
items:
type: array
minItems: 1
Der Prompt an Claude Code sollte die unverhandelbaren Regeln benennen:
Behandle openapi.yaml als Vertrag und implementiere POST /api/orders mit Next.js App Router.
Validiere requestBody mit Zod. Prüfe Authorization: Bearer <token> an der API-Grenze.
Idempotency-Key ist Pflicht. Gleicher Key und gleicher Payload liefern dieselbe Response.
Gleicher Key mit anderem Payload liefert 409. Baue ein 60-Sekunden-Rate-Limit ein.
Alle Fehler verwenden { error: { code, message, requestId, details } }.
Erzeuge Vitest-Tests und einen GitHub-Actions-Workflow.
Für die Review sollten die offiziellen Seiten zu Claude Code und Next.js Route Handlers offen sein.
Die Grenze mit Next.js und Zod schließen
Die API-Grenze ist der Punkt, an dem externe Daten noch nicht vertrauenswürdig sind. Browser, mobile Apps, Partner-Systeme und Webhooks können kaputtes JSON, fehlende Felder, alte Enum-Werte oder doppelte Requests senden. Zod validiert TypeScript-Daten zur Laufzeit; die offizielle Dokumentation steht unter Zod.
Der folgende Code kann in app/api/orders/route.ts kopiert werden. Er nutzt Map, damit das Beispiel lokal ohne Infrastruktur läuft. In Produktion gehören Bestellungen, Idempotenz und Rate Limiting in Datenbank, Redis oder API Gateway.
import { z } from "zod";
export const runtime = "nodejs";
const CreateOrderSchema = z.object({
customerId: z.string().min(3),
currency: z.enum(["JPY", "USD", "EUR"]),
items: z.array(z.object({
sku: z.string().min(1),
quantity: z.number().int().positive().max(99),
})).min(1),
note: z.string().max(500).optional(),
});
type Order = z.infer<typeof CreateOrderSchema> & {
id: string;
status: "accepted";
createdAt: string;
};
const orders = new Map<string, Order>();
const idempotency = new Map<string, { fingerprint: string; status: number; body: unknown }>();
const buckets = new Map<string, { count: number; resetAt: number }>();
export function __resetForTests() {
orders.clear();
idempotency.clear();
buckets.clear();
}
function send(status: number, body: unknown, headers: Record<string, string> = {}) {
return Response.json(body, { status, headers });
}
function fail(status: number, code: string, message: string, requestId: string, details?: unknown) {
return send(status, { error: { code, message, requestId, ...(details ? { details } : {}) } });
}
function actor(req: Request) {
const expected = process.env.API_TOKEN;
const raw = req.headers.get("authorization") ?? "";
const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
return expected && token === expected ? token.slice(0, 12) : null;
}
function allowed(key: string) {
const now = Date.now();
const current = buckets.get(key);
if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + 60_000 });
return true;
}
if (current.count >= 30) return false;
current.count += 1;
return true;
}
export async function POST(req: Request) {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const who = actor(req);
if (!who) return fail(401, "unauthorized", "Invalid API token.", requestId);
if (!allowed(who)) return fail(429, "rate_limited", "Too many requests.", requestId);
const idempotencyKey = req.headers.get("idempotency-key");
if (!idempotencyKey || idempotencyKey.length < 8) {
return fail(400, "missing_idempotency_key", "Idempotency-Key is required.", requestId);
}
const rawBody = await req.text();
const cacheKey = `${who}:${idempotencyKey}`;
const cached = idempotency.get(cacheKey);
if (cached && cached.fingerprint !== rawBody) {
return fail(409, "idempotency_conflict", "Same key was used with another payload.", requestId);
}
if (cached) return send(cached.status, cached.body, { "x-idempotent-replay": "true" });
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return fail(400, "invalid_json", "Request body must be JSON.", requestId);
}
const parsed = CreateOrderSchema.safeParse(payload);
if (!parsed.success) {
return fail(400, "validation_failed", "Request does not match the contract.", requestId, parsed.error.flatten());
}
const order: Order = { ...parsed.data, id: crypto.randomUUID(), status: "accepted", createdAt: new Date().toISOString() };
orders.set(order.id, order);
const body = { data: order, meta: { requestId } };
idempotency.set(cacheKey, { fingerprint: rawBody, status: 201, body });
console.info("orders.create", { requestId, orderId: order.id, itemCount: order.items.length });
return send(201, body, { "x-request-id": requestId });
}
Fehlerformat, Idempotenz und Observability
Ein Fehler-Envelope ist die gemeinsame Form aller Fehlantworten. Ohne stabilen Aufbau kann ein Client nicht zuverlässig entscheiden, ob er erneut versuchen, eine Meldung anzeigen oder Support einschalten soll.
{
"error": {
"code": "validation_failed",
"message": "Request does not match the contract.",
"requestId": "6f0c9c0f-6db7-4bdf-930b-7cc7d13f3f77",
"details": {
"fieldErrors": {
"items": ["Array must contain at least 1 element(s)"]
}
}
}
}
Idempotenz bedeutet, dass ein wiederholter Request keinen zweiten Seiteneffekt erzeugt. Das ist bei Bestellungen, Zahlungen, E-Mails, Gutschriften und Webhooks wichtig. Rate Limiting schützt Infrastruktur und Kosten. Observability heißt schlicht, später nachvollziehen zu können, was passiert ist: requestId, Operation, Ressourcen-ID, Anzahl, Dauer und Fehlercode. Tokens, Adressen, Kartendaten und personenbezogene Informationen gehören nicht in Logs.
Konkrete Anwendungsfälle sind B2B-Bestell-APIs, interne Freigabe-Tools, Webhook-Empfänger und öffentliche APIs mit Free Tier. In allen Fällen helfen eine frühe Auth-Grenze, klare 409-Regeln, ein 429-Format und testbare Fehlercodes.
API-Tests und CI als Übergabe
Tests sollten nicht nachträglich entstehen. Wenn Claude Code Implementierung und Tests im selben Schritt liefert, wird die Review verhaltensbasiert. Dieser Vitest ruft den Route Handler direkt auf.
import { beforeEach, describe, expect, it } from "vitest";
import { POST, __resetForTests } from "../app/api/orders/route";
const validOrder = {
customerId: "cus_123",
currency: "JPY",
items: [{ sku: "book-001", quantity: 2 }],
};
function req(body: unknown, headers: Record<string, string> = {}) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer test-token",
"idempotency-key": crypto.randomUUID(),
...headers,
},
body: JSON.stringify(body),
});
}
describe("POST /api/orders", () => {
beforeEach(() => {
process.env.API_TOKEN = "test-token";
__resetForTests();
});
it("creates an order", async () => {
const res = await POST(req(validOrder));
expect(res.status).toBe(201);
expect((await res.json()).data.status).toBe("accepted");
});
it("rejects invalid input", async () => {
const res = await POST(req({ ...validOrder, items: [] }));
expect(res.status).toBe(400);
expect((await res.json()).error.code).toBe("validation_failed");
});
it("returns 409 for conflicting idempotency reuse", async () => {
const key = "order-key-001";
await POST(req(validOrder, { "idempotency-key": key }));
const res = await POST(req({ ...validOrder, currency: "USD" }, { "idempotency-key": key }));
expect(res.status).toBe(409);
});
});
CI macht den Vertrag zur Repository-Regel. Die Syntax kommt aus der offiziellen GitHub Actions Dokumentation.
name: api-contract
on:
pull_request:
paths:
- "app/api/**"
- "tests/**/*.route.test.ts"
- "openapi.yaml"
jobs:
api-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx @redocly/cli lint openapi.yaml
- run: npx vitest run tests/**/*.route.test.ts
Fallstricke, CTA und Ergebnis
Typische Fehler sind klar benennbar: OpenAPI und Zod laufen auseinander, derselbe Idempotency-Key wird mit anderem Payload akzeptiert, ein Memory-Rate-Limit wird in eine Multi-Instance-Produktion übernommen, interne Fehlerdetails werden nach außen gegeben, oder sensible Daten landen im Log. Auch CI als optionale Nacharbeit ist ein Risiko.
Weitere Details stehen in API-Testautomatisierung und API-Versionierungsstrategie. Teams, die Claude Code in Backend-Prozesse einführen möchten, können mit Training und Beratung starten. Einzelne Entwickler können zuerst das kostenlose Cheatsheet nutzen.
Im praktischen Test war diese Vorgehensweise deutlich leichter zu reviewen als ein isolierter Endpoint. Die 409-Regel, Zod-fieldErrors, Logs mit requestId und OpenAPI-Lint machen aus Claude-Code-Ausgabe einen wartbaren Startpunkt.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.