「Express 5の新機能と最新ベストプラクティスを実コードで把握したい」「TypeScript・middleware・認証・エラーハンドリング・テスト・デプロイまで全部一気通貫で学びたい」。そんな声に応える完全実践ガイドです。本記事は Express 5 + Node.js 22 LTS を前提に、最小サーバーから本番運用までを 40 個以上のコピペで動くコードで解説します。Hono / Fastify との比較、Express 4 からの移行も網羅します。
この記事を最後まで読むと身につくこと
- Express 5 の最小サーバー〜本番運用までの全コード
- TypeScript + Zod + JWT による型安全な REST API の組み立て方
- middleware の順序・async エラー処理・グレースフルシャットダウン
- helmet / cors / rate-limit / compression / morgan など必須エコシステム
- supertest + vitest によるテスト、Docker / PM2 によるデプロイ
1. Express 5 の概要と Node.js 22 環境セットアップ
1.1 Express 5 で何が変わったか
Express 5 は 2024 年に正式版がリリースされ、最大の特徴は async/await の自動エラー伝播、Node.js 18 以上の必須化、依存パッケージの整理、path-to-regexp v8 への更新です。Express 4 で必須だった express-async-errors や手書きの try/catch ラッパーが不要になり、コードが大幅にシンプルになります。
1.2 Node.js / npm のバージョン確認
# Node.js 22 LTS (2024-10 リリース) を推奨
node -v
# v22.11.0
npm -v
# 10.9.0
1.3 プロジェクト初期化
mkdir my-express-app && cd my-express-app
npm init -y
# package.json の "type" を ESM に
npm pkg set type=module
1.4 Express 5 のインストール
# 安定版の Express 5 をインストール
npm install express@5
# TypeScript と型定義
npm install -D typescript tsx @types/node @types/express
# tsconfig.json の初期化
npx tsc --init --target esnext --module nodenext --moduleResolution nodenext
--esModuleInterop --strict --outDir dist
1.5 推奨 tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false
},
"include": ["src/**/*"]
}
1.6 開発スクリプト
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"lint": "eslint ."
}
}
2. 最小 Express サーバーと REST ルーティングの基礎
2.1 最小の Hello World サーバー
// src/index.ts
import express from "express";
const app = express();
const PORT = Number(process.env.PORT ?? 3000);
app.get("/", (req, res) => {
res.json({ message: "Hello Express 5!" });
});
app.listen(PORT, () => {
console.log(`listening on http://localhost:${PORT}`);
});
2.2 app.get / post / put / patch / delete
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
// GET 一覧
app.get("/users", (req: Request, res: Response) => {
res.json({ users: [] });
});
// POST 作成
app.post("/users", (req, res) => {
const { name } = req.body as { name: string };
res.status(201).json({ id: 1, name });
});
// PUT 全更新
app.put("/users/:id", (req, res) => {
res.json({ id: req.params.id, ...req.body });
});
// PATCH 部分更新
app.patch("/users/:id", (req, res) => {
res.json({ id: req.params.id, patch: req.body });
});
// DELETE 削除
app.delete("/users/:id", (req, res) => {
res.status(204).end();
});
2.3 パスパラメータとクエリパラメータ
// /users/42?include=posts
app.get("/users/:id", (req, res) => {
const id = Number(req.params.id); // 42
const include = String(req.query.include); // "posts"
res.json({ id, include });
});
// 複数パラメータ
app.get("/orgs/:orgId/repos/:repoId", (req, res) => {
const { orgId, repoId } = req.params;
res.json({ orgId, repoId });
});
2.4 レスポンス整形のパターン
// JSON
res.json({ ok: true });
// ステータス + JSON
res.status(201).json({ id });
// テキスト
res.type("text/plain").send("ok");
// HTML
res.type("html").send("<h1>hi</h1>");
// 共通ラッパー(API レスポンスの規約化)
function ok<T>(res: Response, data: T, status = 200) {
return res.status(status).json({ ok: true, data });
}
function fail(res: Response, code: string, message: string, status = 400) {
return res.status(status).json({ ok: false, error: { code, message } });
}
3. Router によるモジュール化と大規模設計
3.1 Router の基本
// src/routes/users.ts
import { Router } from "express";
export const usersRouter = Router();
usersRouter.get("/", (req, res) => res.json([]));
usersRouter.get("/:id", (req, res) => res.json({ id: req.params.id }));
usersRouter.post("/", (req, res) => res.status(201).json(req.body));
3.2 親アプリへのマウント
// src/index.ts
import express from "express";
import { usersRouter } from "./routes/users.js";
import { postsRouter } from "./routes/posts.js";
const app = express();
app.use(express.json());
app.use("/api/v1/users", usersRouter);
app.use("/api/v1/posts", postsRouter);
3.3 Router のネストと共通プレフィックス
// /api/v1 の下にさらにグループを束ねる
import { Router } from "express";
import { usersRouter } from "./users.js";
import { postsRouter } from "./posts.js";
export const v1Router = Router();
v1Router.use("/users", usersRouter);
v1Router.use("/posts", postsRouter);
// index.ts
app.use("/api/v1", v1Router);
3.4 Router 単位の middleware 適用
import { Router } from "express";
import { requireAuth } from "../middleware/auth.js";
export const adminRouter = Router();
adminRouter.use(requireAuth("admin")); // この Router 全体に認証
adminRouter.get("/stats", (req, res) => res.json({ ok: true }));
adminRouter.post("/purge", (req, res) => res.json({ purged: true }));
4. middleware の基礎・順序・必須エコシステム
4.1 middleware の基本シグネチャ
import { Request, Response, NextFunction } from "express";
// 最小 middleware: 必ず next() を呼ぶ
function logger(req: Request, res: Response, next: NextFunction) {
console.log(req.method, req.url);
next();
}
app.use(logger);
4.2 middleware の登録順序が重要
// Express は app.use の順に middleware を実行する
// 1) ボディパース → 2) ログ → 3) 認証 → 4) ルート → 5) エラー
app.use(express.json()); // ボディパースは最初
app.use(morgan("combined")); // ログ
app.use(requireAuth()); // 認証
app.use("/api", apiRouter); // ルート
app.use(errorHandler); // エラーは最後
4.3 body-parser(Express 組み込み)
// JSON ボディ(application/json)
app.use(express.json({ limit: "1mb" }));
// URL エンコード(application/x-www-form-urlencoded)
app.use(express.urlencoded({ extended: true, limit: "1mb" }));
// 生のバイト列(Webhook 検証など)
app.use("/webhook", express.raw({ type: "application/json", limit: "100kb" }));
4.4 CORS の設定
npm install cors
npm install -D @types/cors
import cors from "cors";
app.use(
cors({
origin: (origin, cb) => {
const allow = ["https://example.com", "https://app.example.com"];
if (!origin || allow.includes(origin)) cb(null, true);
else cb(new Error("CORS not allowed"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
})
);
4.5 helmet によるセキュリティヘッダ
npm install helmet
import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"default-src": ["'self'"],
"img-src": ["'self'", "data:", "https:"],
"script-src": ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
})
);
4.6 express-rate-limit でレート制限
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分
max: 100, // 1 IP あたり 100 回まで
standardHeaders: "draft-7",
legacyHeaders: false,
message: { ok: false, error: { code: "RATE_LIMITED" } },
});
app.use("/api/", apiLimiter);
4.7 圧縮(compression)
import compression from "compression";
// gzip / brotli ネゴシエーション(レスポンス自動圧縮)
app.use(compression({ threshold: 1024 }));
4.8 ログ:morgan(開発)/ pino(本番)
import morgan from "morgan";
app.use(morgan("dev"));
// 本番は pino-http(JSON 構造化ログで集約しやすい)
import pinoHttp from "pino-http";
import pino from "pino";
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
app.use(pinoHttp({ logger }));
5. リクエストバリデーションとエラーハンドリング
5.1 Zod によるリクエストバリデーション
npm install zod
import { z } from "zod";
import { Request, Response, NextFunction } from "express";
// スキーマ定義
const CreateUser = z.object({
body: z.object({
name: z.string().min(1).max(80),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
}),
});
// 汎用バリデータ middleware
export function validate<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
return res.status(400).json({
ok: false,
error: { code: "VALIDATION", issues: result.error.issues },
});
}
Object.assign(req, result.data);
next();
};
}
// 使い方
app.post("/users", validate(CreateUser), (req, res) => {
res.status(201).json({ id: 1, ...req.body });
});
5.2 Express 5 の async middleware(自動エラー伝播)
// Express 5 では async ハンドラから throw すれば自動で error middleware に飛ぶ
app.get("/users/:id", async (req, res) => {
const user = await db.user.findById(req.params.id);
if (!user) throw new HttpError(404, "USER_NOT_FOUND");
res.json(user);
});
5.3 Express 4 で必要だった async wrapper(参考)
// Express 4 では必要 / Express 5 では不要
export const ah =
<T extends (req: Request, res: Response, next: NextFunction) => Promise<unknown>>(fn: T) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Express 4 での使い方
app.get("/users/:id", ah(async (req, res) => { /* ... */ }));
5.4 カスタムエラークラス
// src/errors.ts
export class HttpError extends Error {
constructor(public status: number, public code: string, message?: string) {
super(message ?? code);
}
}
export class NotFoundError extends HttpError {
constructor(code = "NOT_FOUND") { super(404, code); }
}
export class UnauthorizedError extends HttpError {
constructor(code = "UNAUTHORIZED") { super(401, code); }
}
5.5 エラーハンドリング middleware
// src/middleware/error.ts
import { ErrorRequestHandler } from "express";
import { HttpError } from "../errors.js";
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
if (err instanceof HttpError) {
return res.status(err.status).json({
ok: false,
error: { code: err.code, message: err.message },
});
}
console.error(err);
res.status(500).json({
ok: false,
error: { code: "INTERNAL", message: "internal server error" },
});
};
5.6 404 ハンドラ
// すべてのルートの後 / errorHandler の前
app.use((req, res) => {
res.status(404).json({ ok: false, error: { code: "NOT_FOUND" } });
});
app.use(errorHandler);
6. 認証:JWT / セッション / Cookie
6.1 JWT 発行と検証
npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET!;
export function signToken(payload: object) {
return jwt.sign(payload, SECRET, { expiresIn: "1h", algorithm: "HS256" });
}
export function verifyToken<T = unknown>(token: string): T {
return jwt.verify(token, SECRET) as T;
}
6.2 認証 middleware
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../auth.js";
import { UnauthorizedError } from "../errors.js";
declare global {
namespace Express {
interface Request { user?: { id: string; role: "user" | "admin" } }
}
}
export function requireAuth(role?: "user" | "admin") {
return (req: Request, _res: Response, next: NextFunction) => {
const h = req.headers.authorization;
if (!h?.startsWith("Bearer ")) throw new UnauthorizedError();
try {
const payload = verifyToken<{ sub: string; role: "user" | "admin" }>(h.slice(7));
req.user = { id: payload.sub, role: payload.role };
if (role && payload.role !== role) throw new UnauthorizedError("FORBIDDEN");
next();
} catch {
throw new UnauthorizedError();
}
};
}
6.3 パスワードハッシュとログイン
import bcrypt from "bcryptjs";
import { signToken } from "./auth.js";
app.post("/auth/register", async (req, res) => {
const { email, password } = req.body as { email: string; password: string };
const hash = await bcrypt.hash(password, 12);
const user = await db.user.create({ email, password: hash });
res.status(201).json({ id: user.id });
});
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.user.findByEmail(email);
if (!user) throw new UnauthorizedError();
const ok = await bcrypt.compare(password, user.password);
if (!ok) throw new UnauthorizedError();
const token = signToken({ sub: user.id, role: user.role });
res.json({ token });
});
6.4 cookie-parser
npm install cookie-parser
npm install -D @types/cookie-parser
import cookieParser from "cookie-parser";
app.use(cookieParser(process.env.COOKIE_SECRET));
app.post("/auth/cookie-login", (req, res) => {
res.cookie("sid", "abc123", {
httpOnly: true,
secure: true,
sameSite: "lax",
signed: true,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
res.json({ ok: true });
});
6.5 express-session(セッションベース認証)
npm install express-session connect-redis ioredis
import session from "express-session";
import { RedisStore } from "connect-redis";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
app.use(
session({
store: new RedisStore({ client: redis, prefix: "sess:" }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: "lax", maxAge: 86_400_000 },
})
);
6.6 Passport.js(ローカル戦略)
npm install passport passport-local
npm install -D @types/passport @types/passport-local
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
passport.use(
new LocalStrategy(async (username, password, done) => {
const user = await db.user.findByEmail(username);
if (!user) return done(null, false);
const ok = await bcrypt.compare(password, user.password);
return ok ? done(null, user) : done(null, false);
})
);
app.use(passport.initialize());
app.post("/auth/login", passport.authenticate("local", { session: false }), (req, res) =>
res.json({ token: signToken({ sub: (req.user as any).id }) })
);
7. ファイル・ストリーミング・リアルタイム通信
7.1 静的ファイル配信
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use("/static", express.static(path.join(__dirname, "../public"), {
maxAge: "1d",
immutable: true,
etag: true,
}));
7.2 multer によるファイルアップロード
npm install multer
npm install -D @types/multer
import multer from "multer";
const upload = multer({
storage: multer.diskStorage({
destination: "./uploads",
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
if (!/^image/(png|jpe?g|webp)$/.test(file.mimetype)) {
return cb(new Error("INVALID_FILE_TYPE"));
}
cb(null, true);
},
});
app.post("/upload", upload.single("image"), (req, res) => {
res.json({ filename: req.file?.filename });
});
// 複数ファイル
app.post("/upload-multi", upload.array("images", 10), (req, res) => {
res.json({ count: (req.files as Express.Multer.File[]).length });
});
7.3 テンプレートエンジン(EJS)
npm install ejs
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "../views"));
app.get("/hello", (req, res) => {
res.render("hello", { name: req.query.name ?? "world" });
});
<!-- views/hello.ejs -->
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="utf-8" /><title>Hello</title></head>
<body><h1>Hello, <%= name %>!</h1></body>
</html>
7.4 Pug テンプレート
npm install pug
app.set("view engine", "pug");
// views/index.pug
// doctype html
// html
// head
// title= title
// body
// h1 Hello #{name}
app.get("/", (req, res) => res.render("index", { title: "top", name: "Express" }));
7.5 SSE(Server-Sent Events)
app.get("/sse", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ t: Date.now() })}nn`);
}, 1000);
req.on("close", () => clearInterval(timer));
});
7.6 WebSocket(socket.io)
npm install socket.io
import http from "node:http";
import { Server as IOServer } from "socket.io";
const server = http.createServer(app);
const io = new IOServer(server, { cors: { origin: "*" } });
io.on("connection", (socket) => {
socket.on("message", (msg) => io.emit("message", msg));
});
server.listen(3000);
8. テスト・本番運用・ベストプラクティス
8.1 supertest + vitest によるルートテスト
npm install -D vitest supertest @types/supertest
// src/app.ts (テストしやすいよう listen 前の app を export)
import express from "express";
export const app = express();
app.use(express.json());
app.get("/health", (req, res) => res.json({ ok: true }));
// tests/health.test.ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import { app } from "../src/app.js";
describe("GET /health", () => {
it("200 を返す", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});
8.2 認証付きエンドポイントのテスト
import request from "supertest";
import { app } from "../src/app.js";
import { signToken } from "../src/auth.js";
const token = signToken({ sub: "u1", role: "user" });
it("認証ユーザはアクセスできる", async () => {
const res = await request(app)
.get("/api/v1/me")
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
});
it("トークンなしは 401", async () => {
const res = await request(app).get("/api/v1/me");
expect(res.status).toBe(401);
});
8.3 グレースフルシャットダウン
import http from "node:http";
import { app } from "./app.js";
const server = http.createServer(app);
server.listen(3000);
function shutdown(signal: NodeJS.Signals) {
console.log(`received ${signal}, shutting down...`);
server.close((err) => {
if (err) { console.error(err); process.exit(1); }
// ここで DB プール / Redis 接続を閉じる
process.exit(0);
});
// 強制終了タイムアウト
setTimeout(() => process.exit(1), 15_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
8.4 環境変数バリデーション
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = Env.parse(process.env);
8.5 trust proxy(リバプロ配下の本番)
// Nginx / ALB / Cloud Run の背後では必須
app.set("trust proxy", 1);
// req.ip が X-Forwarded-For ベースで取れるようになる
app.get("/ip", (req, res) => res.json({ ip: req.ip }));
8.6 Dockerfile(マルチステージ)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
8.7 PM2 によるプロセス管理
npm install -g pm2
# ecosystem.config.cjs
module.exports = {
apps: [{
name: "api",
script: "dist/index.js",
instances: "max",
exec_mode: "cluster",
env: { NODE_ENV: "production" },
}],
};
pm2 start ecosystem.config.cjs
pm2 logs api
pm2 restart api --update-env
8.8 Express 4 → 5 の移行ポイント
// 1) async ハンドラからの reject が自動で next(err) に伝播
// 2) req.param() / app.del() / res.send(status) 等の廃止 API を置換
// 3) path-to-regexp v8: ":id?" など一部記法が変更(:id{/...} へ)
// 4) body-parser を組み込みに(express.json / express.urlencoded)
// 例: Express 4 のレガシ書き方
app.del("/x", (req, res) => res.send(204));
// ↓ Express 5
app.delete("/x", (req, res) => res.status(204).end());
8.9 Hono / Fastify との比較表
| 項目 | Express 5 | Fastify | Hono |
|---|---|---|---|
| 主なターゲット | Node.js | Node.js | Edge / Workers / Node |
| 速度 | 標準的 | 非常に高速 | 非常に高速 |
| エコシステム | 圧倒的 | 豊富 | 成長中 |
| 型安全 | @types/express | 標準で強い | 標準で最も強い |
| 学習コスト | 低い | 中 | 低い |
| 採用基準 | 既存資産・教育コスト最優先 | 性能と型を両立 | Edge デプロイ前提 |
8.10 まとめチェックリスト
- Express 5 + Node 22 で
type: "module"を採用したか - helmet / cors / rate-limit / compression / morgan(pino) を入れたか
- Zod でリクエストバリデーションを統一したか
- HttpError + errorHandler でレスポンス規約を揃えたか
- JWT / セッションのどちらを採用するか方針が決まっているか
- supertest でハッピーパス + 401/400/500 をカバーしたか
- SIGTERM/SIGINT のグレースフルシャットダウンを実装したか
- trust proxy / 環境変数バリデーションを本番に入れたか
学習リソース・スクールで体系化する
Express 5 はシンプルですが、認証・テスト・運用まで身につけると現場で即戦力です。独学が辛い人は短期集中で叩き込む選択肢もあります。
- テックアカデミー:Node.js / Express を含む Web 開発が学べる(オンライン専門)
- 侍エンジニア:マンツーマンで Express の API を成果物にできる
- DMM WEBCAMP:Web エンジニア転職を見据えた長期コース
- レバテックキャリア:Node.js / Express の実務求人を確認しキャリア設計

コメント