Yattaskというタスク管理アプリを、Next.js 15(App Router)と Cloudflare Workers を用いて開発しました。本記事では、採用した技術要素と実装方針、運用上の工夫点(Server Actions のキャッシュ再検証、UTC/タイムゾーン、PWA/Push、1ユーザー=1 DO+SQLite など)を、実装例とともに整理して紹介します。実際に作る中で気をつけた点なども触れます。
公開ページ: https://www.yattask.app/
この記事の概要
- App Router × Server Actions のキャッシュ再検証パターンを共有します
- Cloudflare Workers(D1/DO/Queues)と Next.js の境界設計・認証の実例を示します
- DBはUTC/画面はローカルという原則に基づく変換・テストの進め方をまとめます
- PWA/Push 通知の運用ポイント(許可導線、複数端末、失敗時の扱い)を整理します
- Sentry の最小構成と運用時のログ/通知の考え方を記します
対象読者と前提
- Next.js 15 / React 19 の App Router をプロダクション導入したい方
- Cloudflare Workers(Durable Objects + SQLite)を併用した構成を検討している方
- 記事中のコードは最小化のため一部抜粋・簡略化しています(実プロジェクトでは例外処理やセキュリティを適切に補完してください)
全体像(アーキテクチャ)
[Next.js 15 / App Router]
├─ Server Actions (Zod validation)
│ ├─ fetch → CF Workers (/tasks, /projects, ...)
│ └─ revalidateTag / revalidatePath(Router/Data Cache)
├─ Route Handlers (/api/push) ← Workers Queue→Next.jsへのPush中継
├─ Middleware (認証・x-url注入)
└─ PWA (manifest, Service Worker)
[Cloudflare Workers / D1 / Durable Objects / Queues]
├─ RESTエンドポイント(API_KEYで認証)
├─ Push購読の保存(DO内SQLite)
└─ リマインダー通知(Queue→Next.js /api/push)
[ブラウザ]
├─ UI(Tailwind×Radix / shadcn-ui)
├─ Service Worker(push, notificationclick)
└─ PWAインストール/Push購読
なぜこの構成なのか(意図とトレードオフ)
前提と分割方針
- 責務の分離: Next.js はUI/キャッシュ制御/認証ゲート。Workers はドメインロジックと低レイテンシ処理。
- データの性質で分割: D1はユーザー管理のみ。タスク/ラベル/セクション/リマインダー/Push購読は「1ユーザー=1 DO」のSQLiteに保存。
- 非同期イベントはQueuesへ: リマインダーやPushは遅延/再試行可能なQueueで実行。
D1をユーザー管理に限定する理由
- RDBの得意分野: メール/プロバイダIDのユニーク制約や正規化、軽い検索に向く。スキーマの進化もツールが豊富。
- 運用容易: バックアップや手元検証がSQLで完結。SaaS/IdP連携や課金IDなどの属性格納にも相性が良い。
- 更新頻度が低い: ユーザー行は更新が少なく、DO常駐のメリットが相対的に小さい。
Durable Objectsでタスクを扱う理由
- 1ユーザー=1 DOでクエリが爆発しない: 各ユーザーのデータはそのDO内SQLiteに閉じるため、Nユーザー増えても1リクエスト当たりの検索範囲は“そのユーザー分”に限定。
- 強整合・順序保証: 1 DO = 1スレッドで直列実行。並び替え/完了/作成の同時実行でも整合が壊れにくい。
- SQLが使える: DOのSqlStorage(埋め込みSQLite)でインデックス/JOIN/集計などリレーショナルなクエリが可能(例:
tasks × labels
)。 - 自然なシャーディング: user_id単位でオブジェクトを分け、ホットスポットを局所化。
- 低レイテンシ: エッジ常駐+メモリキャッシュで近接応答(同一DOへの連続操作は特に有利)。
- ドメインロジックの集約: 並び替え、カウント更新、繰り返しロールオーバー等を1箇所で一貫実装。
- 環境分離が容易: DO単位でフェイル/実験/設定を切り替えやすく、障害影響範囲を限定できる。
DO採用のデメリット/注意点
- グローバル横断クエリは不得手: テナント横断集計/全文検索は外部基盤やイベント複製(CDC)が必要?
- マイグレーションのオーケストレーション: 既存DOへスキーマ変更をローリング適用(起動時/アクセス時チェック+Alarms等で補助)
- 大規模データの保管: 各DOのサイズに配慮。アーカイブや期間集約の設計が必要
- ベンダーロックイン: CF特有のランタイム/モデルへの依存
- 交差トランザクション不可: 複数DO間での同時整合は難しい。最小化/冪等性/補償で設計
- コールドスタート/移動: インスタンス再配置時のレイテンシばらつき(リトライ/冪等性で吸収)
この構成で得られる具体的な効果
- クエリ爆発がない: 一覧/検索は“そのユーザーのDO内SQLite”にだけヒットし、規模が増えても局所化
- タスク操作が競合しにくい: 並び替え→完了→作成が同時でも一貫した順序で確定
- UI反映が速い: DO内でカウント/並び更新→Next側は
revalidateTag
で最小限の再描画 - 通知が安全: リマインダーはQueuesで遅延/再試行→Nextの
/api/push
でBearer認証を通して送信 - 環境分離: DO単位でのスロットリング/実験/ロールアウトでリスクを局所化。
Server Actions実戦投入:Zod×revalidateの型安全&速度設計
- ポイント
- 入力は Zod スキーマで検証(共有スキーマは packages/shared に集約)
- mutate後は
revalidateTag
/revalidatePath
を最小限の粒度で実行 - Data Cacheタグは“用途に寄せた単語”で一貫命名(例:
tasks-all
,tasks-project-<id>
,task-<id>
,task-count
) -
cache: 'force-cache'
の乱用は避け、タグ再検証を基軸に
例:タスク更新時に関連キャッシュ/パスを再検証(抜粋)
// src/app/actions/task-actions.ts(抜粋)
import { revalidatePath, revalidateTag } from 'next/cache'
const CACHE_TAGS = {
ALL_TASKS: 'tasks-all',
PROJECT_TASKS: (id: string) => `tasks-project-${id}`,
TASK_DETAIL: (id: string) => `task-${id}`,
TASK_COUNT: 'task-count',
}
// 成功後の再検証例
revalidatePath(`/task/${validatedData.id}`)
revalidatePath('/inbox')
revalidateTag(CACHE_TAGS.TASK_DETAIL(validatedData.id))
revalidateTag(CACHE_TAGS.ALL_TASKS)
revalidateTag(CACHE_TAGS.TASK_COUNT)
if (validatedData.project_id) {
revalidateTag(CACHE_TAGS.PROJECT_TASKS(validatedData.project_id))
}
- 曖昧な“全消し”を避け、影響範囲に限定したタグを張る
- 一覧/詳細/カウント/今日のタスクなど“意味ある単位”ごとにタグ化
- fetch の
next: { tags: [...] }
を忘れない
Cloudflare Workers(Hono)連携:API設計と認証
- Next.js → Workers の呼び出し
-
CF_WORKER_URL
+API_KEY
(x-api-key
ヘッダ)で認証 - 失敗時のログを丁寧に出し、失敗でもUIを壊さないAction設計
-
const url = `${WORKER_BASE_URL}/tasks/create`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': WORKER_API_KEY },
body: JSON.stringify(payload),
})
- Workers → Next.js(Push通知中継)
- Queue Consumer→
/api/push
への fetch で Bearer 認証(WORKER_TO_NEXTJS_SECRET
)
- Queue Consumer→
// src/app/api/push/route.ts(抜粋)
const authHeader = request.headers.get('authorization')
const secret = process.env.WORKER_TO_NEXTJS_SECRET
if (!secret || !authHeader?.startsWith('Bearer ') || authHeader.replace('Bearer ', '') !== secret) {
return NextResponse.json({ error: '認証に失敗しました' }, { status: 401 })
}
UTC/タイムゾーン設計
タスク管理において一番ハマったのがこの日付・タイムゾーン処理部分です。
日本時間の頭しかなかったためサマータイムやDBでの日時扱いのベストプラクティスが分からず3日ほど日付処理の理解のために費やしました。
JavaScript/TypeScriptでの日時仕様は混乱しがちですが、標準仕様としてTemporal APIの策定が進んでいるようなので期待です。
- ルール
- DBは常にUTC ISO文字列(末尾Z)
- UIは常にローカルタイムゾーン
- APIの入出力はUTCで統一
- 実装のポイント
- 変換ユーティリティを1カ所に集約(例:
packages/shared/dateTimeUtils.ts
) - テストは
jsdom
+ タイムゾーンモックでDST/日付またぎを検証
- 変換ユーティリティを1カ所に集約(例:
- 使い方(例)
import { convertLocalToUtc, convertUtcToLocal } from '@/utils/dateTimeUtils' // エイリアスで再出力推奨
// フォームで得たローカル日時を送信用UTCに
const iso = convertLocalToUtc(localDate)
// 受け取ったUTCを表示用にローカルへ
const local = convertUtcToLocal(iso)
注意:テストと実装の参照パスがズレやすいです。packages/shared
の実装を src/utils
で再エクスポートする、または paths
alias を追加して統一しましょう。
PWA/Push通知
- 体験設計
- 価値の提示→任意のトリガーで許可要求
- 購読の解除導線もUI上に必ず用意
- 実装の肝
- Service Workerでpush受信→
showNotification
、クリックでアプリ起動 - 複数端末購読はDBでendpointをユニーク管理、全端末へ送る
- Service Workerでpush受信→
// public/sw.js(抜粋)
self.addEventListener('push', event => {
const data = event.data?.json() || {}
event.waitUntil(self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/icon-192x192.png',
data: { url: data.url || '/' },
}))
})
- Next.js APIでの中継&検証(前節のBearer参照)
ミドルウェア/認証:保護ルートとx-url注入
-
src/middleware.ts
- アプリ本体ページのみ認証要求(
matcher
で静的指定) -
auth()
でセッション確認、未ログインは/auth/login
へ - 下流へ現在URLを伝えるため
x-url
ヘッダを付与
- アプリ本体ページのみ認証要求(
function nextWithXUrl(req: NextRequest) {
const requestHeaders = new Headers(req.headers)
requestHeaders.set('x-url', req.url)
return NextResponse.next({ request: { headers: requestHeaders } })
}
モニタリング(Sentry)
-
sentry.edge.config.ts
/sentry.server.config.ts
で初期化 -
withSentryConfig
をnext.config.mjs
に適用 - DSNは環境変数化し、PIIやログ量を運用に合わせて調整
MD/MDXコンテンツ運用
- オウンドメディア化したいかったが、自分一人しか触らないためRDBではなく、MDXファイルベースで記事を用意
-
src/lib/blog.ts
で Frontmatter と本文を読み込み、最小実装でサムネ抽出 - マーケ系は
(marketing)
グループ配下にページ/レイアウトを分離しパフォーマンスと責務を分割
開発体験(Testing/Lint)
- Format/Lint:
pnpm check
(Biome),pnpm lint
(ESLint) - Test:
pnpm test
(Jest + next/jest + jsdom) - Import alias:
@/*
,@content/*
(tsconfig.json
)