Cloudflare Pages + Hono + D1で構築するエッジネイティブAPI基盤:実装から自動化まで
Cloudflare Pages + Hono + D1でエッジネイティブなAPI基盤を構築する完全ガイド。技術選定の根拠・実装手順・Markdown Auto-Discoveryによるブログ自動化・CI/CDまでを一冊にまとめた実践ガイド。
はじめに:「サーバーレスの次」を実装する
Lambda + API Gateway の組み合わせは2015年以降のスタンダードだった。しかし2025〜2026年の視点では、より根本的なトレードオフを持つ選択肢が実用段階に達している。
問題はコールドスタートだけではない。「リクエストのたびに認証・ルーティング・DB接続を最初から実行する」設計そのものの非効率さだ。V8 Isolate ベースの Edge Runtime はこのモデルを覆す。
サーバーレス世代の比較:
第1世代: Lambda + API Gateway(2015〜)
─────────────────────────────────────
長所: イベント駆動、自動スケール、サーバー管理不要
短所: コールドスタート 200ms〜1000ms
API Gateway の設定が複雑
リクエスト数課金(月100万リク以降 $3.5/100万)
リージョンが固定(例:ap-northeast-1のみ)
第2世代: Edge Runtime / V8 Isolate(2022〜実用化)
─────────────────────────────────────
長所: コールドスタート 0ms(Isolateの事前ウォームアップ)
Cloudflare の 300+ PoP でグローバル配信
Workers Free: 月1,000万リクまで無料
デプロイが git push のみ
短所: Node.js APIが使えない(Web API のみ)
CPU時間 50ms 上限
ステートフルな処理に向かない
この記事は2部構成になっている。
- Part 1〜7 ── Cloudflare Pages + Hono + D1 による REST API 基盤の設計・実装・CI/CD
- Part 8〜12 ── Vite バーチャルモジュールを使った Markdown Auto-Discovery(ブログ記事の自動インデックス生成)
Part 1:技術選定の根拠
なぜ Hono か
フレームワーク比較:
Express(Node.js専用)
→ Workers では動かない(Node.js APIに依存)
Fastify(Node.js専用)
→ 同上
Hono(Web Standards ベース)
→ Cloudflare Workers / Deno / Bun / Node.js すべてで動く
→ Web API(Request / Response / Headers)のみ使用
→ バンドルサイズ 13KB(Express の 1/50)
→ TypeScript ファースト、型推論が強力
→ RPC モード:フロントエンドから型安全なAPIコール
→ Zod バリデーターとの公式インテグレーション
Hono の本質は「Web Standards への忠実な実装」だ。c.req は Request オブジェクト、c.json() は Response を返す。Node.js固有の req.body や res.send() は登場しない。
// Hono が Web Standard API のみ使う例
app.post('/api/items', async (c) => {
// c.req.json() は Request.json() と等価
const body = await c.req.json()
// c.json() は new Response(JSON.stringify(...)) と等価
return c.json({ created: true }, 201)
})
なぜ D1 か
エッジからのDB接続比較:
PlanetScale / Neon(リモートDB)
→ HTTP経由のラウンドトリップが毎クエリ発生
→ エッジ → リモートDB のレイテンシ: 50〜200ms
D1(SQLite at the Edge)
→ Workers と同じPoP で SQLite が動く
→ クエリのレイテンシ: 1〜10ms
→ スキーマ・マイグレーションが使える
→ 無料枠: 5GB ストレージ / 日500万行読み取り
適合するユースケース:
✅ コンテンツAPI(読み取り多・書き込み少)
✅ 設定・プロファイルの参照
✅ グローバルに低レイテンシを提供したいDB
❌ 高頻度書き込み(SNSのタイムライン等)
❌ ACID トランザクションを多用する金融系
D1 は「リモートDBをエッジから叩く」のではなく、SQLite がエッジで直接動く。この違いはレイテンシだけでなく、設計の考え方も変える。
Part 2:プロジェクト構成とセットアップ
ディレクトリ構成
my-api/
├── src/
│ ├── index.ts # Honoアプリのエントリーポイント
│ ├── routes/
│ │ ├── items.ts # /api/items
│ │ └── users.ts # /api/users
│ ├── middleware/
│ │ ├── auth.ts # JWT検証ミドルウェア
│ │ └── ratelimit.ts # KV ベースのレートリミット
│ └── types.ts # 型定義(Bindings等)
├── migrations/
│ ├── 0001_create_items.sql
│ └── 0002_create_users.sql
├── .github/
│ └── workflows/
│ └── deploy.yml # GitHub Actions CI/CD
├── wrangler.jsonc
├── vite.config.ts
└── package.json
wrangler.jsonc の設定
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-api",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./dist",
// D1 データベース(本番のみ定義。ローカルは --local で自動SQLite)
"d1_databases": [
{
"binding": "DB",
"database_name": "my-api-production",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
// KV(レートリミット・セッションキャッシュ用)
"kv_namespaces": [
{
"binding": "KV",
"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
],
// R2(ファイルアップロード用)
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "my-api-uploads"
}
]
}
型定義
// src/types.ts
export type Bindings = {
DB: D1Database
KV: KVNamespace
R2: R2Bucket
JWT_SECRET: string // wrangler pages secret put JWT_SECRET
ALLOWED_ORIGINS: string // 許可オリジンのカンマ区切りリスト
}
export type Variables = {
// 認証ミドルウェアがリクエストスコープで設定する変数
userId: string
userEmail: string
}
Part 3:Hono アプリケーションの実装
エントリーポイントとルーター設計
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { itemsRouter } from './routes/items'
import { usersRouter } from './routes/users'
import { authMiddleware } from './middleware/auth'
import { rateLimitMiddleware } from './middleware/ratelimit'
import { type Bindings, type Variables } from './types'
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
// ── グローバルミドルウェア ─────────────────────────
app.use('*', logger())
app.use('*', secureHeaders())
// CORS: 環境変数から許可オリジンを動的に読む
app.use('/api/*', async (c, next) => {
const corsMiddleware = cors({
origin: (origin) => {
const allowed = c.env.ALLOWED_ORIGINS.split(',')
return allowed.includes(origin) ? origin : null
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
})
return corsMiddleware(c, next)
})
// レートリミット(KVベース)
app.use('/api/*', rateLimitMiddleware)
// ── 認証不要のエンドポイント ──────────────────────
app.get('/api/health', (c) => {
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
})
})
// ── 認証が必要なルート ────────────────────────────
const protectedApp = app.use('/api/*', authMiddleware)
protectedApp.route('/api/items', itemsRouter)
protectedApp.route('/api/users', usersRouter)
// ── エラーハンドラ ────────────────────────────────
app.notFound((c) => c.json({ error: 'Not Found' }, 404))
app.onError((err, c) => {
console.error(err)
return c.json({ error: 'Internal Server Error' }, 500)
})
export default app
JWT 認証ミドルウェア
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import { verify } from 'hono/jwt'
import { type Bindings, type Variables } from '../types'
export const authMiddleware = createMiddleware<{
Bindings: Bindings
Variables: Variables
}>(async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const token = authHeader.slice(7)
try {
// hono/jwt は Web Crypto API を使うため、Node.js の crypto 不要
const payload = await verify(token, c.env.JWT_SECRET)
c.set('userId', payload.sub as string)
c.set('userEmail', payload.email as string)
return next()
} catch {
return c.json({ error: 'Invalid token' }, 401)
}
})
D1 を使ったCRUD実装
// src/routes/items.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { type Bindings, type Variables } from '../types'
export const itemsRouter = new Hono<{
Bindings: Bindings
Variables: Variables
}>()
// バリデーションスキーマ(入出力の型定義を兼ねる)
const createItemSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
status: z.enum(['draft', 'published']).default('draft'),
})
const updateItemSchema = createItemSchema.partial()
// ── GET /api/items ────────────────────────────────
itemsRouter.get('/', async (c) => {
const { page = '1', limit = '20', status } = c.req.query()
const offset = (parseInt(page) - 1) * parseInt(limit)
// プリペアドステートメントで SQL インジェクション対策
let query = 'SELECT * FROM items'
const params: unknown[] = []
if (status) {
query += ' WHERE status = ?'
params.push(status)
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(parseInt(limit), offset)
const { results } = await c.env.DB.prepare(query).bind(...params).all()
const countQuery = status
? 'SELECT COUNT(*) as total FROM items WHERE status = ?'
: 'SELECT COUNT(*) as total FROM items'
const countResult = status
? await c.env.DB.prepare(countQuery).bind(status).first<{ total: number }>()
: await c.env.DB.prepare(countQuery).first<{ total: number }>()
return c.json({
items: results,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: countResult?.total ?? 0,
},
})
})
// ── POST /api/items ───────────────────────────────
itemsRouter.post(
'/',
zValidator('json', createItemSchema), // バリデーション失敗で自動400
async (c) => {
const data = c.req.valid('json')
const id = crypto.randomUUID() // Web Crypto API(Node.js不要)
const now = new Date().toISOString()
await c.env.DB.prepare(
'INSERT INTO items (id, title, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).bind(id, data.title, data.description ?? null, data.status, now, now).run()
return c.json({ id, ...data, created_at: now, updated_at: now }, 201)
}
)
// ── PUT /api/items/:id ────────────────────────────
itemsRouter.put(
'/:id',
zValidator('json', updateItemSchema),
async (c) => {
const id = c.req.param('id')
const data = c.req.valid('json')
const existing = await c.env.DB
.prepare('SELECT id FROM items WHERE id = ?')
.bind(id).first()
if (!existing) return c.json({ error: 'Item not found' }, 404)
const updates = Object.entries(data)
.filter(([, v]) => v !== undefined)
.map(([k]) => `${k} = ?`)
const values = Object.values(data).filter((v) => v !== undefined)
if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400)
const now = new Date().toISOString()
await c.env.DB.prepare(
`UPDATE items SET ${updates.join(', ')}, updated_at = ? WHERE id = ?`
).bind(...values, now, id).run()
const updated = await c.env.DB
.prepare('SELECT * FROM items WHERE id = ?')
.bind(id).first()
return c.json(updated)
}
)
// ── DELETE /api/items/:id ─────────────────────────
itemsRouter.delete('/:id', async (c) => {
const id = c.req.param('id')
const { meta } = await c.env.DB
.prepare('DELETE FROM items WHERE id = ?')
.bind(id).run()
if (meta.changes === 0) return c.json({ error: 'Item not found' }, 404)
return c.body(null, 204)
})
Part 4:D1 マイグレーションとローカル開発
マイグレーションファイル
-- migrations/0001_create_items.sql
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at DESC);
-- migrations/0002_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
ローカル開発ワークフロー
# D1 データベースを本番用に作成(初回のみ)
npx wrangler d1 create my-api-production
# → 出力された database_id を wrangler.jsonc に貼り付ける
# ローカルでマイグレーション実行(--local で自動的にローカルSQLiteを使用)
npx wrangler d1 migrations apply my-api-production --local
# シードデータ投入
npx wrangler d1 execute my-api-production --local --command="
INSERT INTO items (id, title, status, created_at, updated_at)
VALUES
('item-1', 'First Item', 'published', datetime('now'), datetime('now')),
('item-2', 'Draft Item', 'draft', datetime('now'), datetime('now'));
"
# ビルド → ローカルサーバー起動
npm run build
npx wrangler pages dev dist --d1=my-api-production --local --port 3000
# 動作確認
curl http://localhost:3000/api/health
curl http://localhost:3000/api/items
curl -X POST http://localhost:3000/api/items \
-H "Content-Type: application/json" \
-d '{"title":"New Item","status":"draft"}'
Part 5:KV・R2 との組み合わせパターン
KV:レートリミット
// src/middleware/ratelimit.ts
import { createMiddleware } from 'hono/factory'
import { type Bindings } from '../types'
export const rateLimitMiddleware = createMiddleware<{
Bindings: Bindings
}>(async (c, next) => {
const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'
const key = `ratelimit:${ip}`
const count = parseInt(await c.env.KV.get(key) ?? '0')
if (count >= 100) {
return c.json({ error: 'Rate limit exceeded' }, 429)
}
// TTL 60秒のスライディングウィンドウ
await c.env.KV.put(key, String(count + 1), { expirationTtl: 60 })
return next()
})
KVはセッションキャッシュとしても使える。D1へのクエリを削減する簡単なパターン:
// D1 + KV のキャッシュレイヤー
async function getCachedUser(
kv: KVNamespace,
db: D1Database,
userId: string
) {
const cached = await kv.get(`user:${userId}`, 'json')
if (cached) return cached
const user = await db
.prepare('SELECT * FROM users WHERE id = ?')
.bind(userId).first()
if (user) {
// 5分間キャッシュ(TTL は用途に応じて調整)
await kv.put(`user:${userId}`, JSON.stringify(user), { expirationTtl: 300 })
}
return user
}
R2:ファイルアップロードエンドポイント
app.post('/api/upload', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file') as File | null
if (!file) return c.json({ error: 'No file provided' }, 400)
// ファイルサイズ制限(10MB)
if (file.size > 10 * 1024 * 1024) {
return c.json({ error: 'File too large (max 10MB)' }, 413)
}
const key = `uploads/${crypto.randomUUID()}/${file.name}`
await c.env.R2.put(key, file.stream(), {
httpMetadata: { contentType: file.type },
})
return c.json({ key, url: `/api/files/${key}` }, 201)
})
app.get('/api/files/:key{.+}', async (c) => {
const key = c.req.param('key')
const object = await c.env.R2.get(key)
if (!object) return c.json({ error: 'Not Found' }, 404)
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000',
},
})
})
Part 6:GitHub Actions による自動デプロイ
手動デプロイは「手順の属人化」と「デプロイ漏れ」を生む。CI/CDに落とし込むことで、git push がそのまま本番反映になる。
# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Build
run: npm run build
# PRのときはプレビューデプロイ、mainマージは本番デプロイ
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-api
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
この設定により:
- PR作成 → プレビューURL が自動発行(レビュー可能)
- mainマージ → 本番に自動デプロイ
- D1 のマイグレーションは本番デプロイ前に別 job で実行する構成にできる
Part 7:パフォーマンス特性とコスト試算
実測値(参考)
レイテンシ比較(東京ユーザー → API):
Lambda + API Gateway(ap-northeast-1):
コールドスタート: 300〜800ms
ウォーム時: 30〜80ms
P99: 200〜500ms
Cloudflare Workers + D1(nrt PoP):
コールドスタート: 0ms(V8 Isolate は事前準備済み)
通常時: 10〜30ms(D1クエリ含む)
P99: 50〜100ms
コスト試算(月100万リクエスト)
| 構成 | 内訳 | 月額 |
|---|---|---|
| Lambda + API Gateway | Lambda $0.2 + API GW $3.5 | $3.70 |
| Cloudflare Workers Free Tier | 月1,000万リクまで | $0 |
| Cloudflare Workers Paid | $5/月 + $0.15/100万 | $5.15 |
低〜中トラフィックの API では Workers の無料枠が長期間収まる。
Part 8:Markdown Auto-Discovery ── 記事を増やすだけで自動反映
ここからは、Hono + Cloudflare Pages でブログを構築するときの「もう一つの実装問題」を扱う。
「記事を追加するたびに index ファイルを手動編集する」問題
多くの実装が陥るパターンがある。
// ❌ よくある「管理コスト隠れ負債」パターン
// src/data/blog.ts
export const blogPosts = [
{
id: '01',
slug: 'first-post',
title: '最初の記事',
date: '2025-01-01',
tags: ['TypeScript'],
excerpt: '...',
content: `...`, // ← ここに本文を直書き
},
{
id: '02',
slug: 'second-post',
// 記事を追加するたびにここを編集
// ...
},
]
このパターンの問題は、記事の追加・編集・削除のたびに blog.ts を手動で同期する必要がある点だ。記事数が増えるほど、この管理コストは線形に膨らむ。ファイルと配列の二重管理になり、どちらが真実の情報源か曖昧になる。
解決策は明快だ。posts/ ディレクトリの Markdown ファイルを唯一の情報源(Single Source of Truth)にする。 インデックスはビルド時に自動生成する。
Part 9:なぜ「ビルド時自動生成」が必要なのか
Cloudflare Workers にはファイルシステムがない
Node.js では fs.readdirSync('posts/') を実行時に呼べる。しかし Cloudflare Workers の実行環境はそれができない。
実行環境の制約:
Node.js サーバー:
├─ fs.readFileSync() ✅ 実行時に使える
├─ fs.readdirSync() ✅ 実行時に使える
└─ 動的ファイル読み込み ✅
Cloudflare Workers (V8 Isolate):
├─ fs モジュール ❌ 存在しない
├─ ファイルシステム ❌ アクセス不可
└─ 実行時に読めるのはバンドルに含まれたコードのみ
ランタイムでファイルを読めないなら、ビルド時にすべて解決するしかない。
Next.js の getStaticProps がそうであるように、「ビルド時に処理を終わらせてランタイムに渡す」のがエッジ環境での基本戦略だ。
Vite の「バーチャルモジュール」という仕組み
Vite には、実在しないファイルのように見えるモジュールをプラグインが動的に生成する「バーチャルモジュール」という機能がある。
// アプリケーションコードからは「ファイルが存在するかのように」インポートできる
import { allPosts } from 'virtual:posts'
// 実際には vite.config.ts のプラグインがビルド時にこの中身を生成している
// ファイルシステムは実行時には存在しない → Cloudflare Workers で動く
これにより、「ビルド時にファイルを読む」という処理をアプリケーションコードから完全に分離できる。
Part 10:設計の全体像
設計のデータフロー:
┌─────────────────────┐
│ posts/*.md │ ← 記事追加はここだけ
│ article-01.md │
│ article-02.md │
│ article-03.md ← 新規追加
└──────────┬──────────┘
│ npm run build
▼
┌─────────────────────┐
│ Vite Plugin │ ← vite.config.ts に定義
│ ① .md を列挙 │
│ ② gray-matter でパース(front matter 抽出)
│ ③ marked で HTML 変換
│ ④ JSON 文字列としてエクスポート
└──────────┬──────────┘
│ バーチャルモジュールとして注入
▼
┌─────────────────────┐
│ virtual:posts │ ← Worker バンドルに含まれる
│ allPosts: Post[] │ (実行時ファイルアクセス不要)
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Hono ルートハンドラ │ ← import { allPosts } from 'virtual:posts'
│ /posts │
│ /post/:slug │
└─────────────────────┘
記事の追加は posts/ にファイルを置くだけ。blog.ts や配列定義、import 文の追加は不要。npm run build がすべてを再生成する。
Part 11:Vite プラグインの実装
フロントマターの設計
Markdown ファイルの先頭に YAML 形式でメタデータを記述する。
---
title: "記事のタイトル"
date: 2026-03-03
readTime: 7
category: Web Dev
tags: Hono, Markdown, TypeScript
description: "OGP・検索結果に表示される説明文"
---
## 本文
本文はここから始まる。
プラグインの実装
// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { marked } from 'marked'
const VIRTUAL_ID = 'virtual:posts'
const RESOLVED_ID = '\0' + VIRTUAL_ID // Vite の内部 ID(\0 プレフィックス必須)
// ① ファイルの読み込みとパース
function loadPosts(postsDir: string) {
if (!fs.existsSync(postsDir)) return []
const files = fs
.readdirSync(postsDir)
.filter((f) => f.endsWith('.md'))
.sort()
return files.map((filename) => {
const raw = fs.readFileSync(path.join(postsDir, filename), 'utf-8')
const { data, content } = matter(raw) // front matter と本文を分離
const slug = filename.replace(/\.md$/, '')
// タグのノーマライズ:配列と文字列両方に対応
const rawTags: unknown = data.tags ?? []
const tags: string[] = Array.isArray(rawTags)
? rawTags.map(String)
: String(rawTags)
.split(',')
.map((t) => t.trim())
.filter(Boolean)
// ② Markdown → HTML 変換(ビルド時に完結)
const contentHtml = marked.parse(content) as string
return {
slug,
title: String(data.title ?? slug),
date: String(data.date ?? ''),
tags,
description: String(data.description ?? ''),
contentHtml,
}
})
}
// ③ Vite プラグイン本体
function markdownPostsPlugin(): Plugin {
const postsDir = path.resolve(__dirname, 'posts')
return {
name: 'markdown-posts',
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID
},
load(id) {
if (id !== RESOLVED_ID) return
// ④ ホットリロード対応
try {
if (fs.existsSync(postsDir)) {
fs.readdirSync(postsDir)
.filter((f) => f.endsWith('.md'))
.forEach((f) => this.addWatchFile(path.join(postsDir, f)))
this.addWatchFile(postsDir)
}
} catch {
// ウォッチ失敗は無視(CI 環境等)
}
const posts = loadPosts(postsDir)
return `export const allPosts = ${JSON.stringify(posts, null, 2)}`
},
}
}
型定義(TypeScript アンビエント宣言)
// src/data/posts.ts
declare module 'virtual:posts' {
export interface Post {
slug: string
title: string
date: string // ISO 8601 e.g. "2026-08-01"
tags: string[]
description: string
contentHtml: string // ビルド時に変換済みの HTML
}
export const allPosts: Post[]
}
import { allPosts } from 'virtual:posts'
export type { Post } from 'virtual:posts'
export { allPosts }
/** 全記事を新しい順に並び替えて返す */
export function getSortedPosts() {
return [...allPosts].sort((a, b) => (a.date < b.date ? 1 : -1))
}
/** スラッグで単一記事を検索 */
export function getPostBySlug(slug: string) {
return allPosts.find((p) => p.slug === slug)
}
/** "2026-08-01" → "2026年8月1日" */
export function formatDateJa(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
}
Hono ルートでの使い方
// src/index.tsx
import { Hono } from 'hono'
import { getSortedPosts, getPostBySlug, formatDateJa } from './data/posts'
const app = new Hono()
// 記事一覧
app.get('/posts', (c) => {
const posts = getSortedPosts()
const listHtml = posts
.map(
(p) => `
<article>
<a href="/post/${p.slug}">
<h2>${p.title}</h2>
<time>${formatDateJa(p.date)}</time>
<p>${p.description}</p>
</a>
</article>`
)
.join('')
return c.html(`<!DOCTYPE html><html><body>${listHtml}</body></html>`)
})
// 個別記事
app.get('/post/:slug', (c) => {
const slug = c.req.param('slug')
const post = getPostBySlug(slug)
if (!post) return c.text('Not Found', 404)
// contentHtml はビルド時に変換済み → ランタイムコストなし
return c.html(`
<!DOCTYPE html>
<html>
<head>
<title>${post.title}</title>
<meta name="description" content="${post.description}">
</head>
<body>
<h1>${post.title}</h1>
<time>${formatDateJa(post.date)}</time>
${post.contentHtml}
</body>
</html>
`)
})
export default app
Part 12:拡張パターンと依存ライブラリの選定
タグ別インデックス
export function getPostsByTag(): Map<string, Post[]> {
const map = new Map<string, Post[]>()
for (const post of allPosts) {
for (const tag of post.tags) {
const list = map.get(tag) ?? []
list.push(post)
map.set(tag, list)
}
}
return map
}
app.get('/tag/:tag', (c) => {
const tag = c.req.param('tag')
const byTag = getPostsByTag()
const posts = byTag.get(tag) ?? []
if (posts.length === 0) return c.text('Not Found', 404)
return c.html(renderPostList(posts, `タグ: ${tag}`))
})
RSS フィードの自動生成
app.get('/rss.xml', (c) => {
const posts = getSortedPosts().slice(0, 20)
const items = posts
.map(
(p) => `
<item>
<title><![CDATA[${p.title}]]></title>
<link>https://example.com/post/${p.slug}</link>
<description><![CDATA[${p.description}]]></description>
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
<guid>https://example.com/post/${p.slug}</guid>
</item>`
)
.join('')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<link>https://example.com</link>
<description>技術ブログ</description>
${items}
</channel>
</rss>`
return c.text(xml, 200, { 'Content-Type': 'application/xml; charset=UTF-8' })
})
依存ライブラリの選定根拠
| ライブラリ | 役割 | 選定理由 |
|---|---|---|
gray-matter |
YAML front matter のパース | デファクトスタンダード。エラーハンドリングが堅牢 |
marked |
Markdown → HTML 変換 | 高速・軽量(バンドルサイズ 27KB)。ビルド時のみ使用 |
node:fs / node:path |
ファイル読み込み | Vite プラグイン(Node.js環境)でのみ使用。Workerには含まれない |
重要なのは node:fs と marked は Vite プラグイン内(Node.js環境)でのみ使用され、Cloudflare Workers のバンドルには含まれないことだ。ランタイムバンドルサイズへの影響がゼロ。
操作の比較(Auto-Discovery の効果)
❌ 手動管理(blog.ts に直書き)の場合:
1. posts/new-article.md を作成
2. src/data/blog.ts を開く
3. blogPosts 配列に新しいオブジェクトを追加
4. title, date, tags, excerpt, content をコピー・転記
→ ファイルを2つ触る。転記ミスのリスクがある。
✅ Auto-Discovery の場合:
1. posts/new-article.md を作成(front matter を書く)
→ それだけ。npm run build で自動反映。
Conclusion:エッジアーキテクチャの適用判断
Cloudflare Pages + Hono + D1 の構成は「Lambda を全部置き換えるもの」ではなく、特定の条件が揃ったときに最も効率的な選択肢になる。
| 条件 | 適合度 | 理由 |
|---|---|---|
| グローバルに低遅延が必要 | ✅ 最適 | 300+ PoP で配信 |
| 読み取り多・書き込み少のDB | ✅ 最適 | D1 の強み |
| サーバー管理ゼロ | ✅ 最適 | Workers は完全マネージド |
| 高頻度書き込み(秒数百件以上) | ❌ 不適 | D1 の書き込みスループット制限 |
| 長時間バッチ処理 | ❌ 不適 | CPU時間 50ms 上限 |
| Node.js 固有ライブラリが必要 | ❌ 不適 | Web API のみ使用可能 |
| 複雑なトランザクション | △ 要検討 | SQLite の制約内で設計が必要 |
設計の判断基準は「エッジで完結できるか」に尽きる。処理が単一リクエストの応答範囲に収まり、DBアクセスが読み取り中心であれば、このスタックは Lambda + RDS 構成と比較してコスト・レイテンシ・運用工数のすべてで優位に立つ。
Markdown Auto-Discovery も同じ思想だ。「自動化」は複雑な仕組みを作ることではなく、人間が手を動かすべき場所をシステムの端点に押し込める設計の話だ。Vite プラグインという「ビルド時の処理」をうまく使うことで、Cloudflare Workers のランタイム制約を回避しながら、それを実現している。
この記事をシェア