REST APIをNodeで実装する:ルーティング・入力検証・エラー統一をClaude Codeで
Express 5でREST APIを実装する手順。ルーティング、Zodでの入力検証、エラーレスポンスの統一、認証ミドルウェア、ページネーションをコピペで動くコード付きで解説。
「とりあえずGETとPOSTのエンドポイント作って」
そうClaude Codeに頼んで出てきたAPIは、ローカルではちゃんと動きました。でも本番に出した翌日、フロント担当から「エラーのときのレスポンス、毎回形が違うんですけど」と言われたんです。あるところは { message }、あるところは生のスタックトレース、あるところは空のボディ。フロント側は分岐コードだらけになっていました。
動くだけのAPIと、人に渡せるAPIは別物です。今日はその差を埋める話をします。テーマはひとつ、**Nodeで「動くREST APIを実装する」**こと。API設計の決め方やテスト、仕様書化は別記事に譲って、ここはひたすら「実装」に集中します。Express 5を使って、ルーティング、入力検証、エラーの統一、認証ミドルウェア、ページネーションを、コピペで動く形で組み立てます。
この記事の要点
- REST API実装でフロントを苦しめる原因のほとんどは、エラーレスポンスの形がバラバラなこと。最初に1か所で統一する。
- Express 5なら async関数の中で投げたエラーが自動でエラーハンドラに飛ぶ。
try/catchを全エンドポイントに書く時代は終わった。 - 入力検証はZodで「境界」を1枚はさむ。検証で落ちたら同じエラー形で返す。
- 認証はミドルウェアにして、ルートの定義から「誰が入れるか」を切り離す。
- Claude Codeへの依頼は「エンドポイント作って」ではなく「この共通部品(エラー・検証・認証)に乗せて実装して」と言うと、出力が一気に安定する。
なぜ「実装」だけ切り出すのか
APIの記事はたいてい全部を一気に語ろうとします。設計、実装、テスト、ドキュメント、デプロイ。でも実際に手を動かすとき、頭の中では別々の作業なんですよね。
この記事が扱うのは真ん中の「実装」だけです。前後はこう送り分けます。
- どんなエンドポイントを切るか、URLやレスポンスの形をどう決めるか → API設計の進め方をClaude Codeと一緒に
- 作ったAPIをどう検証するか、200以外をどう確かめるか → APIテストをsupertest+Vitestで自動化する
- 仕様書(openapi.yaml)に落として型まで生成するか → OpenAPI入門:仕様を書いてSwagger UIで見る
ここから先は「設計はもう決まった。じゃあ手を動かして動くものを作ろう」という前提で進めます。題材は注文API。GET /api/orders(一覧)と POST /api/orders(作成)の2本に絞ります。
なぜExpress 5なのか:try/catch地獄から抜けられる
まず土台の話を少しだけ。
Express 4の時代、async関数の中でエラーを投げると、Expressがそれに気づけませんでした。だから全エンドポイントに try/catch を書いて、catch の中で next(err) を呼ぶ。これを忘れると、エラーが握りつぶされてリクエストが永遠に返ってこない。地味につらいバグでした。
Express 5でここが変わりました。公式のError Handlingにこう書いてあります。
Starting with Express 5, route handlers and middleware that return a Promise will call
next(value)automatically when they reject or throw an error.
つまり、async関数の中で投げたエラーは、自動でエラーハンドラに飛ぶ。try/catch を毎回書かなくていい。ハッピーパスだけ書けばいいんです。これだけでも乗り換える価値があります。Express 5は2024年に安定版になり、執筆時点の最新は5系で、Node.js 18以降が必要です。
身近な例えで言うと、Express 4は「転んだら自分で立ち上がってください」、Express 5は「転んだらマットが受け止めます」。書くコードの量も、事故の数も変わります。
共通部品を3つ先に作る
REST API実装でいちばん大事な発想は、エンドポイントを書く前に共通部品を決めることです。冒頭の「エラーの形がバラバラ」事件は、共通部品なしで各エンドポイントを個別に書いたから起きました。
作る共通部品は3つです。
| 部品 | 役割 | これがないと起きる事故 |
|---|---|---|
| エラー応答の統一 | すべての失敗を同じJSON形で返す | フロントが分岐だらけ、調査が困難 |
| 入力検証(Zod) | 外から来た値を信用せず形を確認 | 壊れた値がDBやロジックまで侵入 |
| 認証ミドルウェア | 入ってよい相手かを最初に判定 | 未認証リクエストにも処理コスト |
この3つを先に置いて、エンドポイントはその上に「乗せる」だけにします。順番が逆だと、あとから統一しようとして全エンドポイントを書き直すはめになります(僕はなりました)。
部品1:エラー応答を1か所に集める
エラーは1種類のクラスと1つのハンドラに集約します。エンドポイント側は「throw new ApiError(...) するだけ」にして、レスポンスの組み立ては考えさせない。これがコツです。
部品2:入力は境界でZodに通す
境界とは、外から来たデータを信用しない場所のこと。ブラウザも外部システムも、平気で壊れた値や古い値を送ってきます。ZodはTypeScript向けのスキーマ検証ライブラリで、入力の形を実行時にチェックできます。検証で落ちたら、部品1のエラー形に変換して返します。
部品3:認証はミドルウェアに切り出す
「誰が入れるか」をルートの定義に直接書くと、エンドポイントが増えるほどコピペが散らばります。認証はミドルウェアにして、必要なルートに requireAuth を1個差すだけにします。
コピペで動く:注文APIの実装一式
ここまでの3部品とエンドポイント2本を、ひとつのファイルにまとめました。@types込みでそのまま動きます。準備はこれだけ。
mkdir orders-api && cd orders-api
npm init -y
npm install express@5 zod
npm install -D typescript tsx @types/express @types/node
本体(server.ts)。長く見えますが、上から「エラー部品 → 検証部品 → 認証部品 → エンドポイント → エラーハンドラ」の順に並んでいるだけです。
// server.ts
import express, { type Request, type Response, type NextFunction } from "express";
import { z, ZodError } from "zod";
// ---- 部品1: エラー応答の統一 ----
// 業務エラーはすべてこのクラスで投げる。レスポンスの組み立ては後段のハンドラに任せる。
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: unknown,
) {
super(message);
}
}
// ---- 部品2: 入力検証(Zod)----
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(),
});
// 一覧のクエリ(?limit=20&cursor=...)も検証する。数値は文字列で来るので coerce で変換。
const ListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.coerce.number().int().min(0).default(0),
});
// ---- 部品3: 認証ミドルウェア ----
// Authorization: Bearer <token> を境界で確認する。通らなければ即401。
function requireAuth(req: Request, _res: Response, next: NextFunction) {
const expected = process.env.API_TOKEN;
const header = req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!expected || token !== expected) {
throw new ApiError(401, "unauthorized", "APIトークンが正しくありません。");
}
next();
}
// ---- データ層(デモ用のメモリ。本番はDBに差し替える)----
type Order = z.infer<typeof CreateOrderSchema> & {
id: string;
createdAt: string;
};
const orders: Order[] = [];
const app = express();
app.use(express.json());
// リクエストごとに追跡用IDを振っておく(調査で効く)
app.use((req, _res, next) => {
(req as Request & { id: string }).id =
req.header("x-request-id") ?? crypto.randomUUID();
next();
});
// ---- エンドポイント: 一覧(ページネーション付き)----
app.get("/api/orders", requireAuth, (req: Request, res: Response) => {
const { limit, cursor } = ListQuerySchema.parse(req.query);
const page = orders.slice(cursor, cursor + limit);
const nextCursor = cursor + limit < orders.length ? cursor + limit : null;
res.json({
data: page,
meta: { limit, nextCursor, total: orders.length },
});
});
// ---- エンドポイント: 作成 ----
// async内で throw しても Express 5 が自動でエラーハンドラへ流す。try/catch 不要。
app.post("/api/orders", requireAuth, async (req: Request, res: Response) => {
const parsed = CreateOrderSchema.parse(req.body);
const order: Order = {
...parsed,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
};
orders.push(order);
res.status(201).json({
data: order,
meta: { requestId: (req as Request & { id: string }).id },
});
});
// ---- 部品1の後段: 唯一のエラーハンドラ(引数4つがエラーハンドラの目印)----
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const requestId = (req as Request & { id: string }).id;
// Zodの検証失敗を、業務エラーと同じ形に変換する
if (err instanceof ZodError) {
return res.status(400).json({
error: {
code: "validation_failed",
message: "リクエストが仕様に合っていません。",
requestId,
details: err.flatten(),
},
});
}
if (err instanceof ApiError) {
return res.status(err.status).json({
error: { code: err.code, message: err.message, requestId, details: err.details },
});
}
// 想定外は中身を漏らさず500。詳細はログにだけ残す。
console.error("unexpected", { requestId, err });
res.status(500).json({
error: { code: "internal_error", message: "予期しないエラーです。", requestId },
});
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`listening on http://localhost:${port}`));
起動と動作確認。
API_TOKEN=dev-token npx tsx server.ts
別のターミナルから叩きます。
# 作成(成功 → 201)
curl -s -X POST http://localhost:3000/api/orders \
-H "authorization: Bearer dev-token" \
-H "content-type: application/json" \
-d '{"customerId":"cus_123","currency":"JPY","items":[{"sku":"book-001","quantity":2}]}'
# 検証で落とす(items空 → 400 validation_failed)
curl -s -X POST http://localhost:3000/api/orders \
-H "authorization: Bearer dev-token" \
-H "content-type: application/json" \
-d '{"customerId":"cus_123","currency":"JPY","items":[]}'
# トークンなし(→ 401 unauthorized)
curl -s http://localhost:3000/api/orders
# 一覧(ページネーション → ?limit=1&cursor=0)
curl -s "http://localhost:3000/api/orders?limit=1&cursor=0" \
-H "authorization: Bearer dev-token"
ポイントは、成功も失敗も全部 { data, meta } か { error } のどちらかで返ってくること。エンドポイント側に try/catch も res.status(400)... の組み立ても一切書いていないこと。これが「実装」の完成形です。
ページネーションは最初から入れる
一覧APIで orders を丸ごと返すコードを、僕は何度も見てきました。データが10件のうちは問題ありません。10万件になった瞬間にAPIが固まります。
上のコードでは ?limit と ?cursor を受け取り、nextCursor を返しています。これはオフセット方式の単純版です。実装で押さえるべきは3点だけ。
- 上限を必ず設ける —
limitはmax(100)で頭打ち。これがないと?limit=999999で殺されます。 - 次ページの在りかを返す —
nextCursor: nullなら最後のページ。フロントが「次があるか」を自分で計算しなくて済みます。 - クエリも検証対象 — クエリ文字列も外から来る入力です。
coerceで数値化しつつZodに通します。
本番でデータ量が増えるなら、cursor をオフセットではなくIDや作成時刻ベースのキーセット方式に変えます。が、それは設計の話なのでAPI設計記事へ。実装の型は上のままで十分動きます。
Claude Codeにどう頼むと安定するか
ここがこの記事の本題かもしれません。同じClaude Codeでも、頼み方で出力の質がまるで違います。
ダメな頼み方は「注文の一覧と作成のエンドポイント作って」。これだと、エラーの形もバラバラ、検証は気分次第、認証は付いたり付かなかったりします。賢さの問題じゃなくて、こちらが基準を渡していないだけなんですよね。
効く頼み方は、共通部品を先に固定して、それに乗せさせること。実際に僕が使っている依頼文がこれです。
server.ts の既存の共通部品(ApiError クラス、Zodスキーマ、requireAuth、
末尾のエラーハンドラ)はそのまま使い、新しいエンドポイントを追加してください。
ルール:
- 失敗は必ず ApiError を throw する。res で直接エラーJSONを組み立てない
- 入力(body と query)は必ずZodスキーマを定義して parse する
- 認証が要るルートには requireAuth を付ける
- async内で try/catch は書かない(Express 5 が自動で拾う前提)
- 成功レスポンスは { data, meta } の形にそろえる
追加するのは GET /api/orders/:id(1件取得、無ければ404 not_found)です。
この頼み方の何がいいかというと、Claude Codeが「何を守るべきか」を毎回ゼロから推測しなくて済むことです。既存の部品を指差して「これに合わせて」と言うだけ。レビューで見る点も「契約を守っているか」の1点に絞れます。
僕がやらかした失敗3つ
正直に書きます。最初に作ったAPIは、上の形にたどり着くまで事故だらけでした。
ひとつ目は、エラー処理を各エンドポイントにベタ書きしたこと。最初は try { ... } catch (e) { res.status(500).send(e.message) } を全部に書いていました。エンドポイントが20本になった頃、エラー形式を変えたくなって、20か所を直すはめに。1か所のエラーハンドラに集約してからは、変更が1行で済むようになりました。
ふたつ目は、Zodの検証を「後で入れる」と先送りしたこと。動くからいいやと飛ばしていたら、quantity: -5 の注文が普通に通っていました。在庫がマイナスになる事故です。境界でZodに通すだけで、こういう「ありえない値」がロジックに届く前に止まります。
みっつ目は、Express 4の癖で try/catch を書き続けたこと。Express 5に上げたのに、惰性で全エンドポイントを try/catch で囲んでいました。あるとき catch の中で next(err) を呼び忘れて、エラーが握りつぶされてリクエストがハングした。Express 5に任せると決めて全部消したら、コードが3割短くなって、ハング事故も消えました。
よくある質問
Q. Express以外(Fastify、Honoなど)でも同じ考え方は使えますか?
A. 使えます。フレームワークが変わっても「エラーは1か所に集約」「入力は境界で検証」「認証はミドルウェア」の3原則は同じです。app.use((err, req, res, next) => ...) に相当する仕組みは各フレームワークにあります。
Q. なぜ入力検証にTypeScriptの型だけでは足りないんですか? A. TypeScriptの型はコンパイル時に消えます。実行時に外から来るJSONは、型がどうあれ何でも入ってくる。だから実行時に動くZodのような検証が要ります。型はあくまで開発時の補助です。
Q. エラーレスポンスの details には何を入れていいですか?
A. 外部に見せてよい情報だけです。Zodの fieldErrors(どの項目がダメか)はOK。DBのエラー文、SQL、トークン、住所やカード番号などの個人情報は絶対に入れません。想定外エラーは中身を返さず500にして、詳細はログにだけ残します。
Q. メモリ上の orders 配列は本番でそのまま使えますか?
A. 使えません。サーバーを再起動すると消えますし、複数台で動かすと共有されません。本番ではこの部分をDB(PostgreSQLなど)に差し替えます。差し替えるのはデータ層だけで、エラー・検証・認証の部品はそのまま使えるのが、この構成の利点です。
Q. 認証トークンを環境変数で固定するのは雑では?
A. デモを最短で動かすための簡易版です。実運用ではJWTの検証やAPIキーのDB照合、OAuthなどに置き換えます。ただし「requireAuth ミドルウェアで境界をふさぐ」という形は変わりません。中身だけ差し替えます。
まとめ:実装の質は「乗せる土台」で決まる
REST APIの実装でつまずく原因は、たいてい個別のエンドポイントの中身ではありません。全体で共通する土台を先に作らなかったことです。
順番はいつも同じです。①エラー応答を1つのクラスと1つのハンドラに集約する→②入力は境界でZodに通す→③認証はミドルウェアに切り出す→④エンドポイントはその上に「乗せる」だけにする→⑤ページネーションには最初から上限と nextCursor を入れる。この土台を先に置いてから、Claude Codeに「これに合わせて」と頼む。
実装が固まったら、次は検証です。手元で動いただけで安心せず、APIテストの自動化で200以外の挙動まで固めると、安心して人に渡せるAPIになります。チームでClaude CodeをAPI開発に入れたいなら研修・導入相談も覗いてみてください。
実際に試した結果
この共通部品方式に切り替えてから、いちばん変わったのはレビューの時間でした。以前は「このエンドポイント、エラー処理どうなってる?」を1本ずつ確認していたのが、いまは「ApiError 投げてる? Zod通してる? requireAuth 付いてる?」の3点を見るだけ。Claude Codeの出力も、土台を指差して頼むようになってから、手戻りがはっきり減りました。冒頭の「エラーの形がバラバラ」とフロントに言われる日は、エラーハンドラを1つにまとめた日を最後に来ていません。賢いAIに丸投げするより、乗せる土台を先に整える。遠回りに見えて、これがいちばん速いというのが今の実感です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。