Desarrollo de APIs de producción con Claude Code: OpenAPI, Next.js, Zod y CI
Crea APIs de producción con Claude Code: contrato OpenAPI, Route Handlers de Next.js, Zod, pruebas y CI.
El error más común al usar Claude Code para crear una API es pedir “un endpoint que funcione” y dar el trabajo por terminado. Para una demo puede bastar, pero una API de producción necesita contrato, validación, autenticación, idempotencia, limitación de tasa, errores consistentes, logs, pruebas y CI.
Esta guía usa Claude Code como compañero de desarrollo backend, no como generador de snippets. El flujo es contract-first: primero se define el contrato con OpenAPI, después se implementa con un Route Handler de Next.js, se valida la entrada con Zod y se deja una entrega verificable con Vitest y GitHub Actions.
Masa probó este enfoque con una API pequeña de pedidos. Cuando el prompt decía solo “crea POST /orders”, cada versión cambiaba la forma del error y el comportamiento ante reintentos. Al incluir contrato, frontera de autenticación, regla de idempotencia, formato de error y comandos de CI, la revisión dejó de ser subjetiva.
Empieza por el contrato OpenAPI
OpenAPI describe rutas, métodos, cuerpos de petición, respuestas y autenticación en un formato legible por máquinas. En términos sencillos, es la promesa de la API antes de escribir la implementación. La referencia oficial es 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
Un prompt útil para Claude Code debe explicar lo que no puede romper:
Usa openapi.yaml como contrato e implementa POST /api/orders con Next.js App Router.
Valida requestBody con Zod. Comprueba Authorization: Bearer <token> al entrar.
Idempotency-Key es obligatorio. Mismo key y mismo payload devuelve la misma respuesta.
Mismo key con payload distinto devuelve 409. Añade rate limit de 60 segundos.
Usa errores { error: { code, message, requestId, details } }.
Incluye pruebas Vitest y un workflow de GitHub Actions.
Para revisar el resultado, ten abiertas las referencias oficiales de Claude Code y Next.js Route Handlers.
Cierra la frontera con Next.js y Zod
La frontera de una API es el punto donde los datos externos dejan de ser confiables. Clientes web, apps móviles, integraciones de socios y webhooks pueden enviar JSON roto, campos antiguos, valores inválidos o peticiones duplicadas. Zod es una librería de validación en tiempo de ejecución para TypeScript; su documentación oficial está en Zod.
El siguiente código puede copiarse en app/api/orders/route.ts. Usa Map para que el ejemplo sea ejecutable sin infraestructura. En producción, pedidos, idempotencia y rate limit deben ir a una base de datos, Redis o un 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 });
}
Diseña errores, idempotencia y observabilidad
Un error envelope es la forma común de todas las respuestas fallidas. Sin una forma estable, el frontend no sabe si debe reintentar, mostrar un mensaje o abrir un ticket. El ejemplo devuelve:
{
"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)"]
}
}
}
}
La idempotencia significa que repetir la misma operación no produce un segundo efecto. Es clave en pedidos, pagos, emails y webhooks. El rate limit evita abuso y protege costes. La observabilidad consiste en dejar evidencia suficiente para investigar: requestId, operación, recurso, cantidad, duración y código de error. No registres tokens, direcciones, tarjetas ni datos personales.
Casos de uso concretos: una API de pedidos B2B llamada por panel interno y socios; una herramienta de aprobación donde el doble clic no debe aprobar dos veces; un receptor de webhooks que recibe reintentos; y una API pública con plan gratuito donde el 429 debe ser claro.
Pruebas de API y CI como entrega
Las pruebas no deben quedar para después. Pide a Claude Code que genere pruebas en el mismo cambio. Este ejemplo de Vitest invoca el Route Handler directamente.
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 convierte la decisión técnica en una regla del repositorio. Usa la sintaxis oficial de GitHub Actions y ejecuta lint de OpenAPI junto a las pruebas.
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
Errores comunes, CTA y resultado
Los errores típicos son muy concretos. Primero, OpenAPI y Zod se desalinean. Segundo, se acepta el mismo idempotency key con otro payload en vez de devolver 409. Tercero, se sube a producción un rate limit en memoria aunque haya varias instancias. Cuarto, se devuelven detalles internos en los errores. Quinto, se registran datos sensibles en logs. Sexto, CI queda como tarea opcional.
Para más profundidad, lee automatización de pruebas API y estrategia de versionado API. Si tu equipo quiere adoptar Claude Code en backend, la salida natural es formación y consultoría. Si prefieres empezar solo, usa la chuleta gratuita.
Al probar este flujo, el resultado fue más fácil de revisar que un endpoint generado sin contrato. La regla 409, los fieldErrors de Zod, los logs con requestId y el lint de OpenAPI hacen que Claude Code entregue un punto de partida mantenible, no solo una demo rápida.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.