Fastifyを使ってみよう - 高速なNode.js APIサーバー入門

intermediate | 60分 で読める | 2026.04.10

公式ドキュメント

この記事の要点

FastifyはExpressの2倍以上高速なNode.js Webフレームワーク
JSON Schema駆動でバリデーションとレスポンスシリアライズを自動最適化
• プラグインアーキテクチャで機能を安全に分離・拡張できる

このチュートリアルで学ぶこと

  • Fastifyプロジェクトのセットアップ
  • ルーティングとリクエスト/レスポンス
  • JSON Schemaによる入力バリデーション
  • プラグインアーキテクチャの理解
  • エラーハンドリング
  • ロギングとライフサイクルフック
  • ユニットテスト(node:test)

FastifyはNode.js向けの低オーバーヘッド・高スループットなWebフレームワークです。Expressに比べてベンチマークで2倍以上の速度を出しつつ、JSON Schemaベースのバリデーションやプラグインシステムなどモダンな機能を備えています。

前提条件・必要な環境

  • Node.js 20以上
  • npm 10以上
  • JavaScriptもしくはTypeScriptの基礎
  • ターミナルとREST APIテストツール(curl / Insomnia / VS Code REST Client)

インストール / セットアップ

mkdir fastify-todo && cd fastify-todo
npm init -y
npm install fastify
npm install -D typescript @types/node tsx
npx tsc --init

package.json にスクリプトを追加します。

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "node --test"
  },
  "type": "module"
}

tsconfig.json の主要設定:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

基本概念

Fastifyインスタンス

fastify() で作成。ルート登録、プラグイン登録、起動を行うコアオブジェクト。

プラグイン

ほぼ全ての機能はプラグインとして提供されます。fastify.register(plugin, opts) でツリー構造に組み込めます。

スキーマバリデーション

リクエストのbody / query / params / headers / responseに対しJSON Schemaを宣言でき、内部的にAjvで高速バリデーションされます。

ライフサイクルフック

onRequest preHandler onSend onResponse などのフックでミドルウェア的処理を挿入できます。

ステップバイステップ実装

シンプルなTodo APIを作っていきましょう。

実践メモ: tsx watchを使うとファイル変更時に自動リロードされ、開発効率が大幅に上がります。

Step 1: 最小サーバー

src/server.ts:

import Fastify from 'fastify';

const fastify = Fastify({
  logger: {
    level: 'info',
  },
});

fastify.get('/health', async () => {
  return { status: 'ok' };
});

const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

起動:

npm run dev
curl http://localhost:3000/health
# => {"status":"ok"}

Step 2: Todoモデルとインメモリストア

src/store.ts:

import { randomUUID } from 'crypto';

export interface Todo {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
}

const todos = new Map<string, Todo>();

export const todoStore = {
  list(): Todo[] {
    return Array.from(todos.values());
  },
  get(id: string): Todo | undefined {
    return todos.get(id);
  },
  create(title: string): Todo {
    const todo: Todo = {
      id: randomUUID(),
      title,
      done: false,
      createdAt: new Date().toISOString(),
    };
    todos.set(todo.id, todo);
    return todo;
  },
  update(id: string, patch: Partial<Pick<Todo, 'title' | 'done'>>): Todo | undefined {
    const todo = todos.get(id);
    if (!todo) return undefined;
    Object.assign(todo, patch);
    return todo;
  },
  remove(id: string): boolean {
    return todos.delete(id);
  },
};

ポイント: データストアをサーバーから分離しておくと、後からDBに差し替える際の変更が最小限で済みます。

Step 3: ルートをプラグイン化

src/routes/todos.ts:

import type { FastifyPluginAsync } from 'fastify';
import { todoStore } from '../store.js';

const todoRoutes: FastifyPluginAsync = async (fastify) => {
  fastify.get('/todos', async () => {
    return todoStore.list();
  });

  fastify.get<{ Params: { id: string } }>(
    '/todos/:id',
    {
      schema: {
        params: {
          type: 'object',
          properties: { id: { type: 'string' } },
          required: ['id'],
        },
      },
    },
    async (request, reply) => {
      const todo = todoStore.get(request.params.id);
      if (!todo) {
        return reply.code(404).send({ error: 'Not Found' });
      }
      return todo;
    },
  );

  fastify.post<{ Body: { title: string } }>(
    '/todos',
    {
      schema: {
        body: {
          type: 'object',
          required: ['title'],
          properties: {
            title: { type: 'string', minLength: 1, maxLength: 100 },
          },
        },
        response: {
          201: {
            type: 'object',
            properties: {
              id: { type: 'string' },
              title: { type: 'string' },
              done: { type: 'boolean' },
              createdAt: { type: 'string' },
            },
          },
        },
      },
    },
    async (request, reply) => {
      const todo = todoStore.create(request.body.title);
      return reply.code(201).send(todo);
    },
  );

  fastify.patch<{
    Params: { id: string };
    Body: { title?: string; done?: boolean };
  }>(
    '/todos/:id',
    {
      schema: {
        body: {
          type: 'object',
          properties: {
            title: { type: 'string', minLength: 1 },
            done: { type: 'boolean' },
          },
          additionalProperties: false,
        },
      },
    },
    async (request, reply) => {
      const updated = todoStore.update(request.params.id, request.body);
      if (!updated) {
        return reply.code(404).send({ error: 'Not Found' });
      }
      return updated;
    },
  );

  fastify.delete<{ Params: { id: string } }>('/todos/:id', async (request, reply) => {
    const ok = todoStore.remove(request.params.id);
    if (!ok) {
      return reply.code(404).send({ error: 'Not Found' });
    }
    return reply.code(204).send();
  });
};

export default todoRoutes;

Step 4: ルートを登録

src/server.ts を更新:

import Fastify from 'fastify';
import todoRoutes from './routes/todos.js';

const fastify = Fastify({ logger: true });

fastify.get('/health', async () => ({ status: 'ok' }));
await fastify.register(todoRoutes, { prefix: '/api' });

try {
  await fastify.listen({ port: 3000, host: '0.0.0.0' });
} catch (err) {
  fastify.log.error(err);
  process.exit(1);
}

Step 5: 動作確認

curl -X POST http://localhost:3000/api/todos \
  -H 'Content-Type: application/json' \
  -d '{"title":"Fastifyを学ぶ"}'

curl http://localhost:3000/api/todos

curl -X PATCH http://localhost:3000/api/todos/<id> \
  -H 'Content-Type: application/json' \
  -d '{"done":true}'

ポイント: スキーマを定義しておけば、不正リクエストはAjvで自動的に400エラーになります。バリデーションコードを書く必要がありません。

curl -X POST http://localhost:3000/api/todos -H 'Content-Type: application/json' -d '{}'
# => 400: body must have required property 'title'

Step 6: グローバルエラーハンドラ

src/server.ts に追加:

fastify.setErrorHandler((error, request, reply) => {
  request.log.error({ err: error }, 'request errored');
  if (error.validation) {
    return reply.code(400).send({ error: 'Bad Request', message: error.message });
  }
  return reply.code(error.statusCode ?? 500).send({
    error: error.name,
    message: error.message,
  });
});

Step 7: フック例 - リクエストログ

fastify.addHook('onRequest', async (request) => {
  request.log.info({ url: request.url, method: request.method }, 'incoming');
});

注意: additionalProperties: falseをスキーマに指定すると、未定義のプロパティがエラーになります。APIの仕様に合わせて慎重に設定してください。

Step 8: テストを書く

test/todos.test.ts:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import Fastify from 'fastify';
import todoRoutes from '../src/routes/todos.js';

async function buildApp() {
  const app = Fastify();
  await app.register(todoRoutes, { prefix: '/api' });
  return app;
}

test('creates and lists todos', async () => {
  const app = await buildApp();

  const created = await app.inject({
    method: 'POST',
    url: '/api/todos',
    payload: { title: 'first' },
  });
  assert.equal(created.statusCode, 201);
  const todo = created.json();
  assert.equal(todo.title, 'first');

  const list = await app.inject({ method: 'GET', url: '/api/todos' });
  assert.equal(list.statusCode, 200);
  assert.equal(list.json().length, 1);

  await app.close();
});

test('rejects empty title', async () => {
  const app = await buildApp();
  const res = await app.inject({
    method: 'POST',
    url: '/api/todos',
    payload: { title: '' },
  });
  assert.equal(res.statusCode, 400);
  await app.close();
});

実行:

npm test

完成コード全体

fastify-todo/
├── src/
│   ├── routes/
│   │   └── todos.ts
│   ├── store.ts
│   └── server.ts
├── test/
│   └── todos.test.ts
├── package.json
└── tsconfig.json

実践メモ: app.inject()を使ったテストはHTTPサーバーを立てずに超高速で検証できます。CIでのテスト時間を大幅に短縮できます。

よくあるエラーと対処

FST_ERR_VALIDATION が連発する

スキーマと実際のbodyが食い違っています。additionalProperties: false を指定していると未定義のキーがエラーになるので注意。

起動時に EADDRINUSE

ポート3000が既に使われています。別のポートを指定するか、占有プロセスを終了させましょう。

lsof -i :3000

await fastify.register(...) の順序

プラグインは登録順に評価されます。依存があるプラグインは順序に気をつけて登録してください

TypeScriptで型が効かない

ジェネリクス Body Params Querystring を明示するか、@fastify/type-provider-typebox などの型プロバイダを使うとスキーマから型推論できます。

ベストプラクティス

  • スキーマを必ず書く: バリデーションだけでなく、レスポンスシリアライズも高速化する
  • プラグインの単位を細かく: 機能ごとに分けてencapsulationを活用
  • ログはpinoをそのまま使う: Fastify標準でpino統合済み。本番ではJSONログとして集約しやすい
  • テストはinjectで書く: HTTPサーバーを立てずに高速テスト
  • 環境変数は @fastify/env で管理: 型付きで安全
  • 本番では disableRequestLogging を検討: 高トラフィック時のログ量を制御

次のステップ(発展課題)

  1. DB接続: @fastify/postgres@fastify/mongodb で永続化
  2. 認証: @fastify/jwt でJWT認証を追加
  3. OpenAPI: @fastify/swagger でドキュメント自動生成
  4. CORS: @fastify/cors を導入
  5. Rate Limit: @fastify/rate-limit でAPI保護
  6. WebSocket: @fastify/websocket でリアルタイム通信
  7. Dockerコンテナ化して本番デプロイ

まとめ

Fastifyは速度・型安全・拡張性を高いレベルで両立したフレームワークです。

  • JSON Schema駆動でバリデーションとレスポンス最適化が同時に得られる
  • プラグインベースなので機能追加が安全
  • 公式エコシステムが豊富で、必要なものはたいてい揃う
  • TypeScript対応がファーストクラス

「Express + α」から始めたいNode.js開発者の次の一手として、Fastifyは最有力候補です。

参考リソース

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る