ClaudeCodeでAPIサーバーを構築する方法【Node.js / Express実践】
Webアプリやスマホアプリの裏側で動くAPIサーバー。一度自分で作ってみたいと思いつつ、「Expressのルーティングが難しい」「データベース連携で挫折した」「認証ってどうするの?」と立ち止まってしまう方は少なくありません。本記事ではClaudeCodeを活用しながら、Node.jsとExpressでシンプルなタスク管理APIを実際に構築するプロセスを解説します。エンドポイント設計、データベース連携、認証、デプロイまでを1本でまとめていますので、ぜひ手を動かしながら読み進めてください。完成する頃にはAPIサーバーの全体像が腑に落ちているはずです。
結論:ClaudeCodeを使えばAPIサーバーは「設計→実装→デプロイ」が一気通貫で進む
ClaudeCodeとExpressの組み合わせは、APIサーバー開発の学習曲線を一気に滑らかにします。具体的には次のような流れで進められます。最初に「タスク管理APIを作りたい」と相談すると、ClaudeCodeはエンドポイントの一覧(例:GET /tasks、POST /tasks、PUT /tasks/:id、DELETE /tasks/:id)を提案してくれます。次に、それぞれに対応するルーター・コントローラ・サービス層を分割した設計を提示し、コードを生成します。データベースはSQLite、PostgreSQL、Cloudflare D1など状況に応じて選べます。さらに、JWTやセッション認証の実装、バリデーション、エラーハンドリング、ロギング、テストコードの生成までシームレスに進みます。最終的にはCloudflare WorkersやRailwayなどへデプロイし、本番運用に乗せるところまで到達できます。
特に強調したいのは、ClaudeCodeが「動くだけのコード」ではなく「保守しやすい構成」を提案してくれる点です。レイヤード設計、入力検証、エラー応答の統一など、プロが当たり前に行う設計指針が標準で盛り込まれます。本記事ではこの恩恵をフルに使って、現場で通用するAPIサーバーを完成させていきます。
h2-1. プロジェクトのセットアップとディレクトリ構成
まずは作業フォルダを作りClaudeCodeを起動します。
mkdir task-api && cd task-api
claude
最初の指示はこちらです。
プロンプト例1:「Node.js + Express + TypeScriptでREST APIサーバーを作りたいです。タスク管理用のAPIで、データベースはまずSQLite(better-sqlite3)を使います。ts-nodeで開発できるように初期化してください。」
ClaudeCodeはpackage.jsonを生成し、必要な依存関係をインストールします。出来上がる構成はこのようなイメージです。
task-api/
├── src/
│ ├── routes/
│ │ └── tasks.ts
│ ├── controllers/
│ │ └── tasksController.ts
│ ├── services/
│ │ └── tasksService.ts
│ ├── db/
│ │ ├── client.ts
│ │ └── schema.sql
│ ├── middleware/
│ │ ├── errorHandler.ts
│ │ └── auth.ts
│ ├── types/
│ │ └── task.ts
│ └── server.ts
├── tsconfig.json
└── package.json
ルートとコントローラ、サービスを分けることで、テストしやすく変更にも強い構造になります。
h2-2. データベース設計とマイグレーション
タスク管理APIのDBスキーマを設計します。最小構成では次のテーブルがあれば十分です。
-- src/db/schema.sql
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo',
due_date TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
プロンプト例2:「上のスキーマを使って、better-sqlite3でDB接続を初期化するモジュールをsrc/db/client.tsに作成してください。アプリ起動時にスキーマが未作成なら自動で作るようにしてください。」
// src/db/client.ts
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
const db = new Database(path.join(process.cwd(), 'data.sqlite'));
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
export default db;
将来的にPostgreSQLや本番DBへ移行することを見越して、DBアクセスはサービス層に集約しておきましょう。
h2-3. ルーティングとコントローラの実装
Expressのルーティングはsrc/routes/tasks.tsに集約します。
// src/routes/tasks.ts
import { Router } from 'express';
import * as ctrl from '../controllers/tasksController';
const router = Router();
router.get('/', ctrl.list);
router.get('/:id', ctrl.get);
router.post('/', ctrl.create);
router.put('/:id', ctrl.update);
router.delete('/:id', ctrl.remove);
export default router;
コントローラは受け取ったリクエストを検証し、サービスに処理を委譲します。
プロンプト例3:「tasksController.tsを作成してください。zodで入力バリデーションし、サービス層を呼び出した結果を返却してください。エラーは次のミドルウェアに渡してください。」
// src/controllers/tasksController.ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as service from '../services/tasksService';
const createSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
status: z.enum(['todo', 'doing', 'done']).optional(),
due_date: z.string().optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
res.json(await service.listTasks());
} catch (e) { next(e); }
}
export async function get(req: Request, res: Response, next: NextFunction) {
try {
const task = await service.getTask(req.params.id);
if (!task) return res.status(404).json({ error: 'Not Found' });
res.json(task);
} catch (e) { next(e); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
const task = await service.createTask(data);
res.status(201).json(task);
} catch (e) { next(e); }
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.partial().parse(req.body);
const task = await service.updateTask(req.params.id, data);
if (!task) return res.status(404).json({ error: 'Not Found' });
res.json(task);
} catch (e) { next(e); }
}
export async function remove(req: Request, res: Response, next: NextFunction) {
try {
const ok = await service.deleteTask(req.params.id);
if (!ok) return res.status(404).json({ error: 'Not Found' });
res.status(204).end();
} catch (e) { next(e); }
}
サービス層は次のように、DB操作だけに専念させます。
// src/services/tasksService.ts
import db from '../db/client';
import { randomUUID } from 'crypto';
export type TaskInput = {
title: string;
description?: string;
status?: 'todo' | 'doing' | 'done';
due_date?: string;
};
export async function listTasks() {
return db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all();
}
export async function getTask(id: string) {
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
}
export async function createTask(data: TaskInput) {
const id = randomUUID();
db.prepare(
`INSERT INTO tasks (id, title, description, status, due_date)
VALUES (?, ?, ?, ?, ?)`
).run(id, data.title, data.description ?? null, data.status ?? 'todo', data.due_date ?? null);
return getTask(id);
}
export async function updateTask(id: string, data: Partial<TaskInput>) {
const current = await getTask(id);
if (!current) return null;
const merged = { ...current, ...data, updated_at: new Date().toISOString() };
db.prepare(
`UPDATE tasks SET title=?, description=?, status=?, due_date=?, updated_at=? WHERE id=?`
).run(merged.title, merged.description, merged.status, merged.due_date, merged.updated_at, id);
return getTask(id);
}
export async function deleteTask(id: string) {
const r = db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
return r.changes > 0;
}
h2-4. エラーハンドリングとロギング
エラー処理を毎回コントローラに書くと冗長になるため、共通ミドルウェアにまとめます。
プロンプト例4:「Zodのバリデーションエラーは400で返し、それ以外の例外は500で返す共通エラーハンドラを作成してください。本番では詳細なエラー内容をクライアントに返さない設計にしてください。」
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof ZodError) {
return res.status(400).json({ error: 'ValidationError', details: err.flatten() });
}
console.error(err);
const message = process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message;
res.status(500).json({ error: message });
}
ロギングはmorganやpinoを使うとよいです。アクセスログを残しておくと、本番でのトラブルシューティングが格段に楽になります。
h2-5. 認証:JWTとCloudflare Accessの使い分け
API認証の方式は用途によって変わります。社内利用や限定公開ならCloudflare Accessが圧倒的におすすめです。Cloudflare Accessはアプリ側で認証ロジックを書かずに、ZeroTrustダッシュボードで「特定メールアドレスのみ許可」「IPアドレスで制限」などを設定できます。独自の認証を書くより安全で、メンテナンスもほぼ不要です。
一方、不特定多数のクライアントから利用される一般公開APIではJWTが標準です。
プロンプト例5:「JWTを使ったログインAPIを実装してください。POST /auth/login でメールとパスワードを受け取り、bcryptで照合した上でJWTを返します。トークン検証ミドルウェアも作成してください。」
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET!;
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
try {
const payload = jwt.verify(header.slice(7), SECRET);
(req as any).user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
ログインエンドポイントは次のようになります。
// src/routes/auth.ts
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import db from '../db/client';
const router = Router();
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user: any = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET!, { expiresIn: '7d' });
res.json({ token });
} catch (e) { next(e); }
});
export default router;
注意:APIキーやJWTシークレットは絶対にソースコードに直書きせず、環境変数で管理してください。Cloudflareにデプロイする場合はwrangler secret putコマンドでSecretsに登録するのが鉄則です。
h2-6. サーバー起動とアプリの組み立て
src/server.tsで全体を組み立てます。
// src/server.ts
import express from 'express';
import tasksRouter from './routes/tasks';
import authRouter from './routes/auth';
import { errorHandler } from './middleware/errorHandler';
import { requireAuth } from './middleware/auth';
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
app.use('/tasks', requireAuth, tasksRouter);
app.use(errorHandler);
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`API listening on :${port}`));
開発時はnpx ts-node-dev src/server.tsで起動できます。curlやPostman、VSCodeのREST Client拡張などで動作確認してみましょう。
curl -X POST http://localhost:3000/tasks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title":"資料作成","status":"todo"}'
h2-7. テストコードの作成
APIサーバーこそテストが効きます。supertestを使うと、Expressアプリを起動せずにエンドポイントを叩いた結果を検証できます。
プロンプト例6:「Jest と supertest を導入し、/tasks の作成・取得・削除をテストするコードを作成してください。テスト用DBは別ファイルに切り替えてください。」
// tests/tasks.test.ts
import request from 'supertest';
import app from '../src/app'; // server.tsからexpressアプリだけexportしておく
describe('Tasks API', () => {
let id: string;
it('creates a task', async () => {
const res = await request(app)
.post('/tasks')
.set('Authorization', `Bearer ${process.env.TEST_TOKEN}`)
.send({ title: 'テスト' });
expect(res.status).toBe(201);
id = res.body.id;
});
it('gets the task', async () => {
const res = await request(app)
.get(`/tasks/${id}`)
.set('Authorization', `Bearer ${process.env.TEST_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.title).toBe('テスト');
});
});
テストを資産として残しておけば、リファクタリングや機能追加でも壊れにくいAPIに育っていきます。
h2-8. デプロイ:Cloudflare Workersへの本番公開
Node.js / ExpressのAPIをCloudflare Workersに乗せる場合は、honoへの移植か、@cloudflare/workers-typesと互換レイヤーを使う方法があります。本格運用するなら、最初からHonoで書くか、ExpressアプリをHonoに置き換える形が現実的です。
プロンプト例7:「このExpressのAPIをHonoに移植してCloudflare Workersで動くようにしてください。DBはCloudflare D1に切り替え、wrangler.jsoncでD1バインディングを設定してください。preview_urlsはfalseにしてください。」
wrangler.jsoncのサンプルです。
{
"name": "task-api",
"main": "src/worker.ts",
"compatibility_date": "2026-05-01",
"preview_urls": false,
"d1_databases": [
{
"binding": "DB",
"database_name": "task-api-db",
"database_id": "<your-d1-id>"
}
]
}
シークレットは次のコマンドで登録します。
wrangler secret put JWT_SECRET
デプロイはwrangler deployの一発です。Cloudflare Accessでメール制限をかければ、社内向けAPIとして安全に運用できます。
FAQ
Q1. なぜサービス層とコントローラを分けるのですか? A. ビジネスロジックとHTTP層を分けることで、後からCLIやバッチからも同じロジックを再利用できるからです。テストも書きやすくなります。
Q2. SQLiteと PostgreSQLどちらを選ぶべきですか? A. 個人プロジェクトや学習用途ならSQLiteで十分です。マルチユーザー・本番運用ならPostgreSQL、Cloudflare上で動かすならD1がベストです。
Q3. APIキーはどう管理すればよいですか? A. 必ず環境変数に入れ、本番ではCloudflareのSecrets機能を使います。ソースコードに直書きするのは絶対NGです。
Q4. CORS対応はどうすればよいですか?
A. corsミドルウェアを使い、許可するオリジンをホワイトリスト方式で指定するのが安全です。
Q5. ファイルアップロードはどう実装しますか?
A. multerなどのミドルウェアを使ってマルチパートを処理し、ファイル本体はR2やS3に保存するのが一般的です。
Q6. レートリミットは必要ですか?
A. 公開APIなら必須です。express-rate-limitやCloudflare WAFのRate Limitingルールを併用すると安心です。
Q7. ドキュメントはどう用意しますか?
A. OpenAPI(Swagger)仕様をClaudeCodeに書かせて、swagger-ui-expressで表示するのが定番です。
まとめ
APIサーバー開発は、設計判断が多くて初心者がつまずきやすい領域ですが、ClaudeCodeを伴走させればそのハードルは大きく下がります。本記事ではタスク管理APIを題材に、ディレクトリ設計、DB連携、コントローラ・サービスの分離、認証、テスト、Cloudflareデプロイまでを一気通貫で実装しました。設計の意図を理解しながら進めることで、フレームワークや言語が変わっても応用が利くスキルが身につきます。次は自分のアイデアを形にして、実際に動くAPIを世の中に出してみてください。