Use Cases (更新: 2026/6/2)

Claude Codeで本番API開発:OpenAPI・Next.js・Zod・CIまでの実践手順

Claude Codeで本番API開発を進める実践ガイド。OpenAPI、Next.js、Zod、認証、冪等性、テスト、CIまで解説。

Claude Codeで本番API開発:OpenAPI・Next.js・Zod・CIまでの実践手順

Claude CodeでAPIを作るとき、一番危ないのは「動くエンドポイントを先に作って満足する」ことです。デモならそれで十分ですが、本番では仕様、認証、入力検証、冪等性、レート制限、エラー形式、ログ、テスト、CIのどこかが抜けると、あとで必ず手戻りになります。

この記事では、Claude Codeを単なるコード生成係ではなく、本番API開発の相棒として使う手順をまとめます。契約優先、つまりOpenAPIでAPIの約束事を先に決め、Next.jsのRoute Handlerで実装し、Zodで境界を守り、テストとCIで引き継げる状態にします。

Masaが小さな受注APIを試作したときは、最初に「POSTだけ作って」と頼んだため、エラー形式と再送時の挙動が毎回ぶれました。そこでClaude Codeへの依頼を「契約、境界、失敗時の挙動、確認コマンド」まで含める形に変えたところ、レビューで見るべき点がかなり減りました。

本番APIは「実装」より先に契約を決める

OpenAPIは、HTTP APIのパス、メソッド、リクエスト、レスポンス、認証方式を機械が読める形で書く仕様です。平たく言えば、APIの仕様書をYAMLやJSONで固定する仕組みです。公式仕様はOpenAPI Specificationを基準にします。

Claude Codeには、最初にこの契約を渡します。ポイントは「コードを書いて」ではなく「この契約から外れない実装にして」と依頼することです。契約があると、Route Handler、Zodスキーマ、テスト、CIのすべてを同じ基準で確認できます。

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

Claude Codeへの依頼文は、次のように具体化します。

openapi.yamlを契約として扱い、Next.js App RouterのRoute Handlerを実装してください。
要件:
- POST /api/ordersだけを実装する
- ZodでrequestBodyを検証する
- Authorization: Bearer <token>をAPI境界で検証する
- Idempotency-Keyを必須にし、同じキーの再送は同じレスポンスを返す
- 同じキーで別payloadが来たら409を返す
- 60秒単位の簡易rate limitを入れる
- エラーは { error: { code, message, requestId, details } } に統一する
- Vitestで正常系、validation失敗、冪等リプレイ、409をテストする
- 実装後に実行すべきCIコマンドも提示する

この依頼で、Claude Codeは「どのファイルを触るべきか」だけでなく「何を守るべきか」まで理解しやすくなります。Claude Code自体の基本はClaude Code overview、Route Handlerの現在の作法はNext.js Route Handlersを確認してください。

Next.js Route HandlerとZodで境界を閉じる

API開発でいう境界とは、外部から入ってくるデータを信用しない場所のことです。ブラウザ、モバイルアプリ、外部システム、Webhookは、すべて壊れた値や古い値を送ってくる可能性があります。ZodはTypeScript向けのスキーマ検証ライブラリで、入力の形を実行時に確認できます。公式情報はZod docsを参照します。

次のコードは、Next.js App Routerのapp/api/orders/route.tsにそのまま置けるサンプルです。実運用では注文保存をDBに置き換え、レート制限と冪等性ストアをRedisなどの共有ストアに移します。コピーして試すため、ここではメモリ上のMapを使っています。

// 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);
  }
}

このサンプルで重要なのは、成功レスポンスより失敗レスポンスを先に設計している点です。認証がない、JSONが壊れている、Zod検証に落ちる、同じ冪等キーで別データが来る、短時間に呼びすぎる。これらは本番で必ず起きます。Claude Codeには、正常系だけでなく失敗系の仕様も同時に渡すべきです。

認証・冪等性・レート制限を最初から入れる

認証境界とは、「このAPIに入ってよい相手か」を最初に判断する場所です。Route Handlerの中でDB処理や外部API呼び出しを始める前に、Authorizationヘッダーを確認します。ここを後回しにすると、未認証リクエストにも計算コストやDB負荷がかかります。

冪等性は、同じリクエストを再送しても結果が二重に増えない性質です。決済、注文、メール送信、ポイント付与などでは特に重要です。たとえばモバイル回線が不安定で、クライアントが同じPOSTを2回送ることがあります。冪等キーを見ずに毎回注文を作ると、二重注文になります。

レート制限は、短時間の呼びすぎを止める仕組みです。サンプルではメモリ上で簡易実装していますが、複数インスタンスで動く本番では共有ストアが必要です。ここをClaude Codeに明示しないと、ローカルでは動くが本番ではすり抜けるコードになりやすいです。

実例としては、少なくとも次の3つで効果があります。

  • B2B SaaSの注文API: 管理画面と外部連携から同じ注文作成APIを呼ぶため、認証境界と冪等性が必須です。
  • 社内管理ツールの承認API: 承認ボタンの二重クリックやブラウザ再送で、同じ承認が2回走るのを防げます。
  • Webhook受信API: 外部サービスは失敗時に再送するため、イベントIDや冪等キーで処理済み判定を入れる必要があります。
  • 公開APIの無料枠: レート制限と一貫した429レスポンスがないと、利用者にも運用者にも原因が見えません。

このあたりはAPIのテスト設計とも直結します。関連する実装観点はAPIテスト自動化ガイドAPIバージョニング戦略も合わせて読むと整理しやすいです。

エラー形式と観測性をAPIの共通部品にする

エラー形式は、クライアント開発者にとっての説明書です。messageだけ返すと、画面で出し分けるべきなのか、再試行すべきなのか、問い合わせに回すべきなのか判断できません。この記事のサンプルでは、エラーを次の形にそろえています。

{
  "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)"]
      }
    }
  }
}

requestIdは問い合わせ対応で効きます。ユーザーから「注文できない」と言われたとき、時刻とメールアドレスだけでログを探すのはつらいです。レスポンスにrequestIdを返し、ログにも同じIDを出すと、調査時間が短くなります。観測性という言葉は難しく聞こえますが、要するに「あとから何が起きたか追える状態」です。

Claude Codeには「ログを追加して」ではなく、ログに何を含めるかを指定します。たとえば、requestId、操作名、リソースID、件数、処理時間、エラーコードです。逆に、APIトークン、住所、カード番号、個人情報はログに出してはいけません。この禁止条件まで書くと、生成結果がかなり安定します。

APIテストをClaude Codeの完了条件にする

本番API開発では、テストを「あとで書く」とほぼ書かれません。Claude Codeへの依頼では、実装と同じタスクにテストを含めます。次のVitestは、Route Handlerを直接呼ぶため、HTTPサーバーを起動しなくても動きます。

// 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");
  });
});

このテストがあると、Claude Codeの出力をレビューするときに「コードの雰囲気」ではなく「契約を守っているか」で判断できます。さらに、失敗例をテスト名に入れておくと、後任の開発者にも意図が伝わります。

CIで契約、型、テストを引き継ぐ

Claude Codeで作ったAPIをチームに渡すときは、CIをハンドオフ資料にします。人間の説明だけに頼ると、次の変更で契約が崩れます。GitHub Actionsの構文はWorkflow syntax for GitHub Actionsを基準にし、OpenAPI lint、型チェック、APIテストを同じワークフローに入れます。

{
  "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

Claude Codeには、実装後に「CIで落ちそうな点を先にレビューして」と追加で頼みます。特に、環境変数API_TOKENがテストで設定されているか、OpenAPIの必須項目とZodの必須項目がずれていないか、Route Handlerのキャッシュ設定が意図通りかを確認させます。

よくある落とし穴

落とし穴は具体的に潰しておきます。1つ目は、OpenAPIとZodを別々に直して差分が生まれることです。契約を変えたらテストも変える、Zodだけ変えない、というルールをClaude Codeの指示に入れます。

2つ目は、冪等性を「同じキーなら常にOK」と誤解することです。同じキーで別payloadが来たら、サンプルのように409を返します。ここを許すと、クライアントのバグをサーバーが隠してしまいます。

3つ目は、レート制限をメモリだけで本番投入することです。サーバーが複数台あると、各インスタンスが別々に数えるため制限が甘くなります。本番ではRedis、データストア、API Gatewayなどに移す必要があります。

4つ目は、エラーに内部情報を出すことです。detailsにZodの検証結果を出すのは便利ですが、DBエラー、SQL、トークン、個人情報は返してはいけません。Claude Codeに「外部公開してよい情報だけ返す」と明記します。

5つ目は、ログに個人情報を混ぜることです。ログは調査のために残ります。注文IDや件数はよいですが、住所、電話番号、カード情報は不要です。ログ設計もAPI設計の一部として扱います。

6つ目は、CIを最後に付け足すことです。CIがないと、Claude Codeで速く作った差分ほど壊れやすくなります。実装、テスト、CIを同じ作業単位にしてください。

収益化につなげる導線

API記事は、読者の悩みがかなり実務寄りです。単にコードを見せるだけで終わらせず、「自分のプロジェクトに安全に入れるにはどうするか」まで案内すると、収益導線が自然になります。チームでClaude CodeをAPI開発に入れたい場合はClaude Code研修・導入相談へ、まず手元でプロンプトとチェックリストを試したい場合は無料チートシートへ誘導すると、記事の内容と読者の次の行動がつながります。

Masaの実感では、API開発記事は「便利そう」だけでは購入や相談につながりません。契約、テスト、CI、障害時の調査という運用の不安まで扱うと、チーム導入を考えている読者が具体的に相談しやすくなります。

まとめ

Claude Codeで本番APIを作るコツは、速くコードを書かせることではなく、守るべき契約を先に渡すことです。OpenAPIで仕様を固定し、Next.js Route Handlerで境界を作り、Zodで入力を検証し、認証、冪等性、レート制限、エラー形式、ログ、テスト、CIまで同じ流れに含めます。

この記事で紹介した内容を実際に試した結果、単発の「APIを作って」という依頼より、レビュー時の指摘が明確になりました。特に、冪等キーの409、ZodのfieldErrorsrequestId付きログ、CIのOpenAPI lintを最初から入れると、Claude Codeの出力を本番に近い品質で受け取れるようになります。

#Claude Code #API開発 #OpenAPI #Next.js #Zod #CI
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。