Pengembangan API produksi dengan Claude Code: OpenAPI, Next.js, Zod, dan CI
Bangun API produksi dengan Claude Code: kontrak OpenAPI, Route Handler Next.js, validasi Zod, test, dan CI.
Kesalahan paling umum saat memakai Claude Code untuk membuat API adalah berhenti pada “endpoint yang bisa jalan”. Untuk demo, itu terlihat cepat. Untuk produksi, API juga membutuhkan kontrak, validasi input, autentikasi, idempotency, rate limiting, format error yang stabil, log, test, dan CI.
Panduan ini memakai Claude Code sebagai partner pengembangan API produksi, bukan sekadar generator kode. Alurnya contract-first: definisikan janji API dengan OpenAPI, implementasikan lewat Next.js Route Handler, validasi batas input dengan Zod, lalu serahkan ke tim melalui Vitest dan GitHub Actions.
Masa mencoba pola ini pada API order kecil. Ketika prompt hanya berbunyi “buat POST /orders”, bentuk error dan perilaku retry berubah di setiap iterasi. Setelah kontrak OpenAPI, batas autentikasi, aturan idempotency, error envelope, dan perintah CI dimasukkan, review menjadi jauh lebih objektif.
Mulai dari kontrak OpenAPI
OpenAPI mendeskripsikan path, method, request body, response, dan autentikasi API HTTP dalam format yang bisa dibaca tool. Dengan bahasa sederhana, OpenAPI adalah janji API sebelum implementasi. Rujukan resminya adalah 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
Prompt untuk Claude Code perlu menyebutkan hal yang tidak boleh dilanggar:
Anggap openapi.yaml sebagai kontrak dan implementasikan POST /api/orders dengan Next.js App Router.
Validasi requestBody dengan Zod. Cek Authorization: Bearer <token> di batas API.
Idempotency-Key wajib. Key dan payload yang sama mengembalikan response yang sama.
Key yang sama dengan payload berbeda mengembalikan 409. Tambahkan rate limit 60 detik.
Semua error memakai { error: { code, message, requestId, details } }.
Buat test Vitest dan workflow GitHub Actions.
Saat review, cocokkan output dengan dokumentasi resmi Claude Code dan Next.js Route Handlers.
Tutup batas input dengan Next.js dan Zod
Batas API adalah titik saat data dari luar belum boleh dipercaya. Browser, aplikasi mobile, sistem partner, dan webhook bisa mengirim JSON rusak, field hilang, enum lama, atau request berulang. Zod adalah library validasi runtime untuk TypeScript; dokumentasi resminya ada di Zod.
Kode berikut bisa ditempatkan di app/api/orders/route.ts. Contoh ini memakai Map agar mudah dijalankan lokal. Untuk produksi, order storage, idempotency, dan rate limit sebaiknya dipindah ke database, Redis, atau 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 });
}
Error envelope, idempotency, rate limit, dan observability
Error envelope adalah bentuk umum untuk semua response gagal. Tanpa bentuk yang stabil, client tidak tahu apakah harus retry, menampilkan pesan, atau mengirim requestId ke support.
{
"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)"]
}
}
}
}
Idempotency berarti operasi yang sama bisa dikirim ulang tanpa membuat efek samping kedua. Ini penting untuk order, pembayaran, email, kredit, dan webhook. Rate limiting melindungi infrastruktur dan biaya. Observability berarti sistem meninggalkan jejak yang cukup: requestId, nama operasi, resource ID, jumlah item, durasi, dan kode error. Token, alamat, nomor kartu, dan data pribadi tidak boleh masuk log.
Use case yang nyata: API order B2B yang dipakai dashboard internal dan partner; tool approval yang harus tahan double click; webhook receiver yang menerima retry dari provider; dan API publik dengan free tier yang membutuhkan response 429 konsisten.
Test API dan CI sebagai definisi selesai
Test jangan ditunda. Masukkan ke prompt Claude Code yang sama dengan implementasi. Vitest berikut memanggil Route Handler langsung tanpa menjalankan server HTTP.
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 membuat kontrak menjadi aturan repository. Gunakan sintaks resmi GitHub Actions dan jalankan OpenAPI lint bersama test API.
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
Pitfall, CTA, dan hasil praktik
Pitfall yang perlu dicegah: OpenAPI dan Zod tidak sinkron; idempotency key yang sama diterima dengan payload berbeda; rate limit berbasis memory dipakai di production multi-instance; error membuka detail internal; log menyimpan token atau data pribadi; dan CI dianggap opsional.
Untuk lanjut, baca otomasi test API dan strategi versioning API. Tim yang ingin mengadopsi Claude Code untuk backend bisa menuju training dan konsultasi. Developer individu bisa mulai dari cheatsheet gratis.
Saat dicoba, alur ini jauh lebih mudah direview dibanding prompt “buat endpoint”. Aturan 409, Zod fieldErrors, log dengan requestId, dan OpenAPI lint membuat hasil Claude Code menjadi fondasi API yang bisa dirawat, bukan hanya demo cepat.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.