このポートフォリオをHono + Cloudflare Pages + D1 + Resendで作った話
Hono + Cloudflare Pages + D1 + Resend でこのポートフォリオサイトを構築した記録。技術選定の理由・ブログのMarkdown自動検出・D1への保存・Resendでのメール通知など、実際に動いているコードをベースに書いています。
はじめに
このポートフォリオサイトは Hono + Cloudflare Pages + D1 + Resend で作っています。
- Hono : Cloudflare Workers で動く軽量 Web フレームワーク
- Cloudflare Pages : 静的アセット配信 + Functions 実行
- D1 : Workers と同じ PoP で動く SQLite(問い合わせ保存用)
- Resend : 問い合わせフォームのメール通知(API 1本で送れる)
技術選定の理由
Hono を選んだ理由
Hono を選んだ一番の理由はシンプルです。
- Cloudflare Workers で動く(Node.js 固有の API に依存していない)
- TypeScript との相性が良い
- バンドルサイズが小さい(13KB 程度)
Express や Fastify は Node.js 専用なので Workers 上では動きません。Hono は Request / Response などの Web 標準 API だけを使うため、Cloudflare Workers でそのまま動きます。
// Hono は Web 標準 API のみ使う
app.post('/api/items', async (c) => {
const body = await c.req.json() // → Request.json() と等価
return c.json({ created: true }, 201) // → new Response() と等価
})
D1 を選んだ理由
問い合わせフォームのデータなど、軽いリレーショナルデータを保存したい場面がありました。
Cloudflare D1 は Workers と同じ PoP で SQLite が動くため、リモート DB へのラウンドトリップが発生しません。無料枠(5GB / 日500万行読み取り)も個人ポートフォリオには十分です。
Lambda + 外部DB の場合: Worker → ネットワーク → リモートDB(50〜200ms)
D1 の場合: Worker → 同じPoP の SQLite(1〜10ms)
※上記のレイテンシ数値は Cloudflare 公式ドキュメントからの参考値です。
Resend を選んだ理由
問い合わせフォームからのメール通知には Resend を使っています。AWS SES ではなく Resend にした理由は、
- API 1本で送れる(
POST https://api.resend.com/emailsに JSON を投げるだけ) - Workers からそのまま
fetchで叩ける(SDK 不要でnode:依存もなし) - 独自ドメイン送信の DNS 設定がシンプル(SPF / DKIM を Cloudflare DNS に貼るだけ)
- 無料枠が個人ポートフォリオには十分(月3,000通)
SES も使えますが、Workers から叩くには AWS の署名付きリクエスト(SigV4)を実装するか SDK を入れる必要があり、「問い合わせが1日数通来るかどうか」の規模には重装備でした。Resend は Bearer トークンを Authorization ヘッダに入れるだけ なので、Workers とは相性が非常に良いです。
プロジェクト構成
webapp/
├── src/
│ ├── index.tsx # Hono アプリのエントリーポイント
│ ├── routes/ # 各ページのルートハンドラ
│ └── data/
│ └── posts.ts # 記事データの型定義・ヘルパー関数
├── posts/ # Markdown 記事ファイル
│ ├── article-01.md
│ └── article-02.md
├── public/ # 静的ファイル(CSS、favicon 等)
├── vite.config.ts
├── wrangler.jsonc
└── package.json
wrangler.jsonc の設定
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "webapp",
"compatibility_date": "2026-03-03",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": "./dist",
// D1 データベース(本番のみ定義。ローカルは --local で自動SQLite)
"d1_databases": [
{
"binding": "DB",
"database_name": "d1-template",
"database_id": "50e5f727-de34-49cc-b7dd-6bd8b93c263a"
}
]
// RESEND_API_KEY は secret で管理:
// npx wrangler pages secret put RESEND_API_KEY
}
RESEND_API_KEY は wrangler.jsonc には書かず、wrangler pages secret put で登録しています。Secret として登録した値は Worker 内で c.env.RESEND_API_KEY として型付きで参照できます。
D1 の使い方
マイグレーションファイル
-- migrations/0001_contacts.sql
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
company TEXT DEFAULT '',
type TEXT NOT NULL,
budget TEXT DEFAULT '',
message TEXT NOT NULL,
ip TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 検索用インデックス
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
CREATE INDEX IF NOT EXISTS idx_contacts_type ON contacts(type);
CREATE INDEX IF NOT EXISTS idx_contacts_created_at ON contacts(created_at DESC);
ローカル開発の流れ
# ローカルでマイグレーション実行(--local で自動的にローカル SQLite を使う)
npx wrangler d1 migrations apply d1-template --local
# ビルド → ローカルサーバー起動
npm run build
npx wrangler pages dev dist --d1=d1-template --local --port 3000
--local フラグを付けると、wrangler が .wrangler/state/v3/d1/ 配下にローカル SQLite を自動作成します。本番と同じ binding 名(DB)で動くため、コードを変えずにローカル開発できます。
Hono ルートでの D1 操作 + Resend 通知
問い合わせフォームは、D1 への保存 と Resend でのメール通知 を1つのハンドラで両方やっています。
// src/index.tsx
type Bindings = {
DB: D1Database
RESEND_API_KEY: string
}
app.post('/api/contact', async (c) => {
const { name, email, company, type, budget, message } = await c.req.json()
// 1. D1 に保存
if (c.env?.DB) {
await c.env.DB.prepare(`
INSERT INTO contacts (name, email, company, type, budget, message, ip)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(
name, email, company || '', type, budget || '', message,
c.req.header('CF-Connecting-IP') || 'unknown'
).run()
}
// 2. Resend でメール通知
if (c.env?.RESEND_API_KEY) {
try {
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: '[email protected]',
subject: `【お問い合わせ】${name} さんから(${type})`,
text: `■ 名前: ${name}\n■ メール: ${email}\n\n${message}`,
}),
})
} catch (mailErr) {
// メール失敗はログのみ。D1保存は成功しているので 200 を返す
console.error('Resend error:', mailErr)
}
}
return c.json({ success: true })
})
実装する上で意識したポイント:
- D1 保存を先、Resend 通知を後 にする。Resend が落ちてもデータは D1 に残るので取りこぼさない
- Resend 失敗時は例外を握りつぶして200を返す 。保存は成功しているので、ユーザーには「受け付けました」と返して、運用側で D1 を見に行けば気づける
- D1 はプリペアドステートメントを使うことで SQL インジェクションを防ぐ
- 送信元 IP は Cloudflare の
CF-Connecting-IPヘッダから取得(Workers の定番)
Resend は fetch 1発で済むので、SDK を入れずに Workers のバンドルサイズを小さく保てるのも気に入っている点です。
管理用エンドポイント
問い合わせ内容を確認するための管理用APIも合わせて実装しています。
// GET /api/contacts — 問い合わせ一覧(Bearer認証付き)
app.get('/api/contacts', async (c) => {
const auth = c.req.header('Authorization')
const adminKey = c.env?.ADMIN_API_KEY
if (!adminKey || auth !== `Bearer ${adminKey}`) {
return c.json({ error: 'Unauthorized' }, 401)
}
const result = await c.env.DB.prepare(
'SELECT id, name, email, company, type, budget, created_at FROM contacts ORDER BY created_at DESC LIMIT 100'
).all()
return c.json({ contacts: result.results, total: result.results.length })
})
// GET /api/health — DB疎通確認
app.get('/api/health', async (c) => {
const result = await c.env.DB.prepare('SELECT COUNT(*) as cnt FROM contacts').first<{ cnt: number }>()
return c.json({ status: 'ok', contacts: result?.cnt ?? 0 })
})
ADMIN_API_KEY はソースコードにベタ書きせず、wrangler pages secret put ADMIN_API_KEY で secret として登録しています。
Markdown Auto-Discovery(ブログ自動化の仕組み)
このサイトのブログ記事は posts/ ディレクトリの Markdown ファイルが自動で反映されます。記事を追加するとき、インデックスファイルを手動で編集する必要はありません。
なぜ必要か
Cloudflare Workers にはファイルシステムがありません。Node.js の fs.readdirSync() は実行時に呼べないため、ビルド時にすべての記事情報をバンドルに組み込む必要があります。
この問題を解決するのが Vite の「バーチャルモジュール」機能です。
posts/*.md
↓ npm run build(Vite プラグインが処理)
virtual:posts(バーチャルモジュール)
↓ import { allPosts } from 'virtual:posts'
Hono ルートハンドラ(実行時にファイルアクセス不要)
Vite プラグインの実装
// vite.config.ts(抜粋)
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
function loadPosts(postsDir: string) {
if (!fs.existsSync(postsDir)) return []
return fs
.readdirSync(postsDir)
.filter((f) => f.endsWith('.md'))
.sort()
.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)
return {
slug,
title: String(data.title ?? slug),
date: String(data.date ?? ''),
tags,
description: String(data.description ?? ''),
contentHtml: marked.parse(content) as string, // ビルド時に HTML 変換
// 任意メタデータ:記事カードの表示・カテゴリ絞り込みで使う
category: String(data.category ?? ''),
readTime: Number(data.readTime ?? 0),
}
})
}
function markdownPostsPlugin() {
const postsDir = path.resolve(__dirname, 'posts')
return {
name: 'markdown-posts',
resolveId(id: string) {
if (id === VIRTUAL_ID) return RESOLVED_ID
},
load(id: string) {
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)}`
},
}
}
node:fs と marked は Vite プラグイン内(Node.js 環境)でのみ使われ、Workers のバンドルには含まれません。
記事追加の手順
❌ バーチャルモジュールなしの場合:
1. posts/new-article.md を作成
2. src/data/blog.ts を開いてオブジェクトを追加
3. title, date, tags をコピー・転記
→ ファイルを2つ触る。転記ミスのリスクがある。
✅ このサイトの場合:
1. posts/new-article.md を作成(front matter を書く)
→ それだけ。npm run build で自動反映。
GitHub Actions による自動デプロイ
このサイトは main ブランチへの push で Cloudflare Pages に自動デプロイされます。
# .github/workflows/deploy.yml(概要)
on:
push:
branches: [main]
jobs:
deploy:
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: webapp
directory: dist
設定自体はシンプルですが、OIDC 認証やデプロイ戦略については、ここでは割愛します。
使用ライブラリと外部サービス
| 名前 | 役割 |
|---|---|
hono |
Web フレームワーク(Workers 対応) |
gray-matter |
Markdown の front matter パース(ビルド時のみ) |
marked |
Markdown → HTML 変換(ビルド時のみ) |
| Cloudflare D1 | 問い合わせ保存用の SQLite(同一 PoP) |
| Resend | メール通知(fetch で API を直接叩く。SDK なし) |
gray-matter と marked はビルド時(Vite プラグイン内)のみ使用しており、Workers のランタイムバンドルには含まれません。Resend も SDK を入れず fetch で叩いているので、ランタイム側の依存は hono だけというシンプルな構成になっています。
まとめ
このスタックで感じているメリットは主に3点です:
- git push だけでデプロイが完結する(サーバー管理が不要)
- 記事の追加が Markdown ファイルを置くだけで済む
- 問い合わせフォームが D1 保存 + Resend 通知の2つだけで完結する(SDKも追加DBも不要)
一方で、Cloudflare Workers の制約(Node.js API が使えない、CPU 時間 50ms 上限)は常に意識する必要があります。複雑な処理には向きません。
この記事をシェア