Production API Development with Claude Code: OpenAPI, Next.js, Zod, and CI
Build production APIs with Claude Code using OpenAPI, Next.js Route Handlers, Zod validation, tests, and CI.
The fastest way to create a risky API with Claude Code is to ask for “an endpoint that works” and stop there. A demo endpoint can be impressive, but a production API needs a contract, validation, authentication, idempotency, rate limiting, consistent errors, logs, tests, and a CI handoff.
This guide treats Claude Code as a production API development partner, not just a code generator. The workflow is contract-first: define the API with OpenAPI, implement it with a Next.js Route Handler, validate inputs with Zod, and make the handoff testable in GitHub Actions.
Masa tested this pattern on a small order API. When the prompt only said “build POST /orders”, each revision changed the error shape and retry behavior. Once the prompt included the contract, auth boundary, idempotency rule, error envelope, and CI checks, the generated code became much easier to review.
Start With The Contract, Not The Handler
OpenAPI is a machine-readable way to describe HTTP APIs: paths, methods, request bodies, responses, and authentication. In plain terms, it is the API promise written before implementation. Use the official OpenAPI Specification as the reference.
Give Claude Code the contract first. The important instruction is not “write some code”; it is “do not drift from this contract.” Once the contract is fixed, the Route Handler, Zod schema, tests, and CI can all be checked against the same source of truth.
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
content:
application/json:
schema:
$ref: "#/components/schemas/OrderResponse"
"400":
description: Invalid request
"401":
description: Missing or invalid token
"409":
description: Idempotency key reused with a different 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
items:
type: object
required: [sku, quantity]
properties:
sku:
type: string
minLength: 1
quantity:
type: integer
minimum: 1
maximum: 99
note:
type: string
maxLength: 500
OrderResponse:
type: object
required: [data, meta]
properties:
data:
type: object
meta:
type: object
properties:
requestId:
type: string
A good Claude Code prompt is explicit about the non-negotiable behavior:
Treat openapi.yaml as the contract and implement a Next.js App Router Route Handler.
Requirements:
- Implement only POST /api/orders
- Validate requestBody with Zod
- Check Authorization: Bearer <token> at the API boundary
- Require Idempotency-Key and replay the same response for the same key and payload
- Return 409 when the same key is reused with a different payload
- Add a simple 60-second rate limit
- Use one error envelope: { error: { code, message, requestId, details } }
- Add Vitest coverage for success, validation failure, idempotent replay, and 409
- Include the CI commands required to verify the result
That prompt tells Claude Code what to preserve, not just what to create. Keep the official Claude Code overview and Next.js Route Handlers documentation open while reviewing the output.
Close The Boundary With Next.js And Zod
The API boundary is the place where outside data stops being trusted. Browsers, mobile apps, partner systems, and webhook providers can all send missing fields, stale enum values, malformed JSON, or duplicate requests. Zod is a TypeScript-first schema validation library that checks those shapes at runtime; use the official Zod documentation for details.
The following file can be pasted into app/api/orders/route.ts in a Next.js App Router project. For production, move order storage, idempotency, and rate limiting to a shared database or Redis. The sample uses Map so the flow is runnable locally and testable without extra infrastructure.
// app/api/orders/route.ts
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 CreateOrderInput = z.infer<typeof CreateOrderSchema>;
type Order = CreateOrderInput & {
id: string;
status: "accepted";
createdAt: string;
};
type ErrorCode =
| "unauthorized"
| "rate_limited"
| "missing_idempotency_key"
| "idempotency_conflict"
| "invalid_json"
| "validation_failed"
| "internal_error";
const orders = new Map<string, Order>();
const idempotencyStore = new Map<
string,
{ fingerprint: string; status: number; body: unknown }
>();
const rateBuckets = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 30;
export function __resetForTests() {
orders.clear();
idempotencyStore.clear();
rateBuckets.clear();
}
function json(status: number, body: unknown, headers: Record<string, string> = {}) {
return Response.json(body, { status, headers });
}
function errorResponse(
status: number,
code: ErrorCode,
message: string,
requestId: string,
details?: unknown,
) {
return json(status, {
error: {
code,
message,
requestId,
...(details ? { details } : {}),
},
});
}
function requireActor(req: Request) {
const expected = process.env.API_TOKEN;
const header = req.headers.get("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!expected || token !== expected) {
return null;
}
return token.slice(0, 12);
}
function takeRateLimit(actor: string) {
const now = Date.now();
const current = rateBuckets.get(actor);
if (!current || current.resetAt <= now) {
rateBuckets.set(actor, { count: 1, resetAt: now + WINDOW_MS });
return true;
}
if (current.count >= MAX_REQUESTS) {
return false;
}
current.count += 1;
return true;
}
export async function POST(req: Request) {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const startedAt = Date.now();
try {
const actor = requireActor(req);
if (!actor) {
return errorResponse(401, "unauthorized", "Invalid API token.", requestId);
}
if (!takeRateLimit(actor)) {
return errorResponse(429, "rate_limited", "Too many requests.", requestId);
}
const idempotencyKey = req.headers.get("idempotency-key");
if (!idempotencyKey || idempotencyKey.length < 8) {
return errorResponse(
400,
"missing_idempotency_key",
"Idempotency-Key header is required.",
requestId,
);
}
const rawBody = await req.text();
const cacheKey = `${actor}:${idempotencyKey}`;
const cached = idempotencyStore.get(cacheKey);
if (cached && cached.fingerprint !== rawBody) {
return errorResponse(
409,
"idempotency_conflict",
"The same Idempotency-Key was used with a different payload.",
requestId,
);
}
if (cached) {
return json(cached.status, cached.body, {
"x-request-id": requestId,
"x-idempotent-replay": "true",
});
}
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return errorResponse(400, "invalid_json", "Request body must be JSON.", requestId);
}
const parsed = CreateOrderSchema.safeParse(payload);
if (!parsed.success) {
return errorResponse(
400,
"validation_failed",
"Request body does not match the API 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 },
};
idempotencyStore.set(cacheKey, {
fingerprint: rawBody,
status: 201,
body,
});
console.info("orders.create", {
requestId,
orderId: order.id,
itemCount: order.items.length,
durationMs: Date.now() - startedAt,
});
return json(201, body, { "x-request-id": requestId });
} catch (error) {
console.error("orders.create.failed", { requestId, error });
return errorResponse(500, "internal_error", "Unexpected server error.", requestId);
}
}
The important part is that the failure modes are designed before the happy path becomes comfortable. Missing auth, malformed JSON, contract violations, repeated idempotency keys with different payloads, and bursts of traffic are all normal production events. Ask Claude Code to implement them from the start.
Add Auth, Idempotency, And Rate Limits Early
An auth boundary is the first decision point: is this caller allowed to enter the API at all? It must run before database work or external API calls. If auth is bolted on later, unauthenticated requests may still create load or leak timing behavior.
Idempotency means that retrying the same operation does not create a second side effect. It matters for payments, orders, email sends, credit grants, and webhook processing. A mobile client may retry after a network timeout even if the first request succeeded. Without an idempotency key, the server cannot tell whether the second POST is a retry or a new order.
Rate limiting protects both your infrastructure and your users. The sample uses in-memory counters for clarity, but a real multi-instance deployment needs a shared store. Tell Claude Code where the sample is intentionally simple and where production must differ.
This pattern helps in at least four practical cases:
- B2B SaaS order APIs: admin screens and partner systems may both create orders, so auth and idempotency must be consistent.
- Internal approval tools: double clicks and browser retries should not approve the same item twice.
- Webhook receivers: providers often retry failed events, so event IDs or idempotency keys are required.
- Public free-tier APIs: users need a predictable 429 response and operators need clear logs.
For adjacent implementation details, pair this article with API test automation and API versioning strategy.
Standardize The Error Envelope And Observability
An error envelope is the response shape every failure uses. If one endpoint returns { message }, another returns { errors }, and another throws HTML, client developers cannot build reliable handling. The sample uses this shape:
{
"error": {
"code": "validation_failed",
"message": "Request body does not match the API contract.",
"requestId": "6f0c9c0f-6db7-4bdf-930b-7cc7d13f3f77",
"details": {
"formErrors": [],
"fieldErrors": {
"items": ["Array must contain at least 1 element(s)"]
}
}
}
}
The requestId is not decoration. When a customer reports that an order failed, you can search logs by the same ID returned to the client. Observability simply means the system leaves enough evidence for you to understand what happened later.
Be precise when prompting Claude Code. Instead of “add logs,” say that logs must include requestId, operation name, resource ID, item count, duration, and error code. Also say what must never be logged: API tokens, addresses, card data, and other personal information.
Make API Tests Part Of Done
If tests are a follow-up task, they usually slip. Put them in the original Claude Code request. This Vitest example calls the Route Handler directly, so no HTTP server is required.
// tests/orders.route.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { POST, __resetForTests } from "../app/api/orders/route";
function buildRequest(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),
});
}
const validOrder = {
customerId: "cus_123",
currency: "JPY",
items: [{ sku: "book-001", quantity: 2 }],
};
describe("POST /api/orders", () => {
beforeEach(() => {
process.env.API_TOKEN = "test-token";
__resetForTests();
});
it("creates an order with a request id", async () => {
const res = await POST(buildRequest(validOrder));
const body = await res.json();
expect(res.status).toBe(201);
expect(body.data.status).toBe("accepted");
expect(body.meta.requestId).toBeTruthy();
});
it("rejects a request that violates the contract", async () => {
const res = await POST(buildRequest({ ...validOrder, items: [] }));
const body = await res.json();
expect(res.status).toBe(400);
expect(body.error.code).toBe("validation_failed");
expect(body.error.details.fieldErrors.items).toBeDefined();
});
it("replays the same response for the same idempotency key and payload", async () => {
const key = "order-key-001";
const first = await POST(buildRequest(validOrder, { "idempotency-key": key }));
const second = await POST(buildRequest(validOrder, { "idempotency-key": key }));
expect(first.status).toBe(201);
expect(second.status).toBe(201);
expect(second.headers.get("x-idempotent-replay")).toBe("true");
expect(await second.json()).toEqual(await first.json());
});
it("returns 409 when the same idempotency key is reused with another payload", async () => {
const key = "order-key-002";
await POST(buildRequest(validOrder, { "idempotency-key": key }));
const res = await POST(
buildRequest(
{ ...validOrder, currency: "USD" },
{ "idempotency-key": key },
),
);
const body = await res.json();
expect(res.status).toBe(409);
expect(body.error.code).toBe("idempotency_conflict");
});
});
These tests turn review into a contract discussion. You can ask whether the API should return 400 or 409, whether the error code is stable, and whether the replay behavior is acceptable. That is much better than reviewing generated code by style alone.
Hand Off With CI
When a Claude Code-generated API moves to a team repository, CI is the handoff. Human notes can drift, but a pull request check keeps enforcing the contract. Use the official GitHub Actions workflow syntax and run OpenAPI linting plus API tests together.
{
"scripts": {
"lint:openapi": "redocly lint openapi.yaml",
"test:api": "vitest run tests/**/*.route.test.ts",
"check:api": "npm run lint:openapi && npm run test:api"
},
"dependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"@redocly/cli": "^1.34.0",
"vitest": "^3.0.0"
}
}
name: api-contract
on:
pull_request:
paths:
- "app/api/**"
- "tests/**/*.route.test.ts"
- "openapi.yaml"
- "package.json"
- "package-lock.json"
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: npm run check:api
After implementation, ask Claude Code to review likely CI failures before you push: missing API_TOKEN in tests, drift between OpenAPI and Zod required fields, and route behavior that depends on local-only memory.
Pitfalls To Catch Before Production
The first pitfall is contract drift. If OpenAPI says currency is required but Zod treats it as optional, client SDKs and runtime behavior disagree. Make Claude Code update the contract, schema, and tests in the same change.
The second pitfall is weak idempotency. “Same key means OK” is incomplete. The same key with a different payload should return 409, otherwise the server hides a client bug.
The third pitfall is in-memory rate limiting in production. It works for a single local process, but not across multiple instances. Move it to Redis, a data store, or an API gateway before real traffic.
The fourth pitfall is oversharing errors. Zod field errors are useful; SQL errors, tokens, and personal data are not. Tell Claude Code exactly which details can leave the server.
The fifth pitfall is logging sensitive data. Logs are retained and searched. Record request IDs, operation names, resource IDs, counts, durations, and error codes, not addresses or credentials.
The sixth pitfall is treating CI as optional. Claude Code accelerates changes, which means regressions can also arrive faster. Contract linting and API tests are the minimum guardrail.
Monetization CTA
API readers usually have a real implementation problem, not a curiosity problem. A useful article should naturally lead to the next step. Teams planning to adopt Claude Code for backend work can start with Claude Code training and consultation. Solo developers who want the prompts and checks first can use the free cheatsheet.
In Masa’s experience, an API article converts better when it discusses operational anxiety: retries, test coverage, CI, and debugging. Those are the points that make a technical reader willing to ask for help.
Summary
The production pattern is simple: give Claude Code the contract before the implementation. Define OpenAPI first, implement a Next.js Route Handler, close the boundary with Zod, add auth, idempotency, rate limiting, a stable error envelope, logs, tests, and CI in one task.
I tried this flow on a small order API. Compared with a plain “build an endpoint” prompt, the result was easier to review because the 409 idempotency rule, Zod fieldErrors, requestId logging, and OpenAPI linting were already present. That is the difference between fast demo code and code a team can maintain.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.