1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

月額ほぼ0円でIPA資格学習Webアプリを運用する:Next.js+Hono+Cloudflareで詰まった3つの罠

1
Last updated at Posted at 2026-08-04

はじめに

IPA資格の過去問、教科書、学習履歴、実力分析、音声学習を一つにまとめたWebアプリ「IT資格ラボ」を個人開発しました。

以前はVercelやSupabaseを使った構成も試していましたが、今回は次の2点を検証するため、インフラをCloudflareへ寄せています。

  • 個人開発でも固定費を抑えたまま、実用的なサービスを運用できるか
  • Pages、Workers、D1、KV、R2を同じエコシステムに置くことで、構成と運用をどこまで単純化できるか

結論から言うと、小〜中規模の個人開発とCloudflareの相性はかなり良いと感じました。一方で、「SQLiteが動くから普通のRDBと同じ」「Hono RPCなら型共有は自動で解決」「Cookieはログイン時に設定すれば終わり」と考えると、しっかり詰まります。

この記事では完成した機能の紹介よりも、実装で特に工夫した点と、実際に遭遇した次の問題を中心にまとめます。

  1. 約800件の一括インポートでD1 APIが500になった
  2. Next.jsとHono Workerを分けたら、ログイン後のAPIが401になった
  3. Honoのサーバー型をWebへ直接共有したら、モノレポの型チェックが重くなった

Cloudflareの制限・無料枠は2026年8月時点の公式ドキュメントを確認して記載しています。最新値は記事末尾の公式リンクも確認してください。

作ったもの

対象は、ITパスポート、基本情報技術者、情報処理安全確保支援士などのIPA試験を学習するWebアプリです。

主な機能は以下です。

  • 年度別・分野別の過去問演習
  • 解答履歴、お気に入り、連続正解数の保存
  • 資格・分野ごとの教科書/用語集
  • 定着度、カバー率、分野別レーダーによる学習分析
  • 長文・記述式問題の演習
  • R2に保存した用語音声の連続再生
  • パスキー/Google認証
  • 管理画面からの問題、教科書、階層データのインポート

「問題を表示するだけ」ならもっと小さな構成にできます。しかし、公開問題の大量読み込みと、ユーザーごとの細かな学習履歴を同時に扱うため、データの性質に応じた分離が必要になりました。

システム構成

フロントエンドとAPIはTypeScriptで統一し、pnpm+Turborepoのモノレポで管理しています。

主な技術スタックです。

レイヤー 技術
フロントエンド Next.js 14 App Router、React、Tailwind CSS、TanStack Query
API Hono、@hono/zod-openapi、Zod
DB Cloudflare D1、Drizzle ORM
認証 WebAuthn(パスキー)、Google OAuth、JWT、Workers KV
オブジェクトストレージ Cloudflare R2
非同期処理 Cloudflare Queues
モノレポ pnpm Workspace、Turborepo
品質保証 ESLint、TypeScript、Vitest、Playwright、Storybook、Sentry

モノレポは「実行単位」と「共有契約」を分けた

ディレクトリは、大きくappspackagesに分けています。

apps/
├── web/        # Next.js
└── server/     # Hono Worker

packages/
├── db/         # Drizzleのテーブル定義
├── schema/     # ZodスキーマとAPI境界の型
├── constants/  # キャッシュ時間などの共通定数
├── ui/         # 共通UI
└── utils/      # 日付処理など

特に意識したのは、Zodを「フォームの入力チェック」だけで終わらせず、API境界の契約として扱うことです。

たとえば内部テーブルのIDは、D1では一貫してintegerにしています。URLでは文字列として届きますが、境界でz.coerce.number()に変換し、サービス層へ文字列IDを持ち込まない方針です。

const numericIdSchema = z.coerce.number().int().positive();

const route = createRoute({
  request: {
    params: z.object({ id: numericIdSchema }),
  },
  // ...
});

これは地味ですが、AIエージェントに実装を任せた際、モック由来の"q-1"やUUIDが内部IDへ入り込むのを防ぐ重要なガードレールになりました。

D1は1個に詰め込まず、データの性質で3分割した

D1は無料プランの場合、1データベースあたり500MB、アカウント全体で5GB、最大10データベースです。そこで、単純に容量だけで分割するのではなく、変更頻度とアクセス範囲で3つに分けました。

DB 主なデータ 性質
Master DB 資格、問題、分野、シリーズ、教科書 全ユーザー共通。読み取り中心
User DB ユーザー、認証情報、設定、契約 個人情報・権限。整合性重視
History DB 解答履歴、お気に入り、しおり、論述状態 ユーザーごとに書き込みが増える

分割の代償:DBをまたぐJOINはできない

分割すると、Master DBの問題とHistory DBの解答履歴をSQLで直接JOINできません。

そのためAPIでは、次の順序で必要な情報だけを取得し、サービス層で合成しています。

  1. Master DBから公開問題と資格情報を取得
  2. History DBから対象ユーザーの資格別進捗を取得
  3. Map<number, ...>へ変換してアプリケーション層で結合

これは単一DBより実装量が増えます。一方で、公開データのキャッシュをユーザー履歴から切り離せること、履歴DBだけを将来水平分割できることは大きな利点でした。

学習履歴は「ユーザー×資格」で1行に集約

1問解くたびに1行追加する設計は分かりやすいものの、長期運用では行数と書き込み回数が増えます。そこで現在は、ユーザーと資格の組み合わせを複合主キーにし、問題別状態をJSONへ集約しています。

export const userQualificationStates = sqliteTable(
  "user_qualification_states",
  {
    userId: integer("user_id").notNull(),
    qualificationId: integer("qualification_id").notNull(),
    favoritesJson: text("favorites_json").notNull().default("[]"),
    progressJson: text("progress_json").notNull().default("{}"),
    bookmarkTermId: integer("bookmark_term_id"),
  },
  (table) => [
    primaryKey({ columns: [table.userId, table.qualificationId] }),
  ],
);

この方式は、資格画面を開く際に1行読めば状態マップを復元できる点が便利です。

ただし、現在の実装はJSONを読み、アプリケーションで変更し、UPSERTするread-modify-writeです。同時更新が多くなると、後から保存した内容で先の更新を上書きする可能性があります。今後アクセスが増えた場合は、SQLiteのJSON関数による部分更新、書き込みの直列化、またはイベントログ方式を検討します。

「JSON集約=常に正解」ではなく、無料枠と現在の書き込み頻度に合わせた選択です。

失敗1:約800件の一括インポートで500エラー

管理画面には、教科書用語をExcel/JSONからまとめて取り込む機能があります。

小さなデータでは動いていたため、そのまま約800件の用語を一つのdb.batch()へ渡したところ、Worker側で500エラーになりました。

最初は入力データやDrizzleの型変換を疑いましたが、件数を減らすと成功します。そこで処理を「検証」と「反映」に分け、書き込みを50ステートメントずつ実行するよう変更しました。

const chunkSize = 50;

for (let i = 0; i < queries.length; i += chunkSize) {
  const chunk = queries.slice(i, i + chunkSize);
  await db.batch(chunk as [any, ...any[]]);
}

Cloudflareの公式仕様では、個々のSQLだけでなく、batch全体にも実行時間などの制限が適用されます。巨大なbatchを一度に投げるより、失敗時の範囲が分かるサイズへ分けた方が運用しやすくなります。

IN (...)にも100バインド変数の上限がある

D1には、1クエリあたりのバインドパラメータが最大100個という制限があります。

関連する教科書用語や階層リンクをまとめて取得する処理で、ID数が100を超えると同じ問題が起きます。上限ちょうどでは、ほかの条件が追加された際に余裕がないため、読み取りは80件ずつに分割しました。

const D1_IN_QUERY_CHUNK_SIZE = 80;

const chunkArray = <T>(items: T[], size: number): T[][] => {
  const chunks: T[][] = [];
  for (let i = 0; i < items.length; i += size) {
    chunks.push(items.slice(i, i + size));
  }
  return chunks;
};

const rows = (
  await Promise.all(
    chunkArray(ids, D1_IN_QUERY_CHUNK_SIZE).map((chunk) =>
      db.query.textbookTerms.findMany({
        where: inArray(textbookTerms.id, chunk),
      }),
    ),
  )
).flat();

ここでの学びは、上限値を定数にして一か所へ閉じ込めることです。各機能でslice(0, 100)を個別に書くと、後から条件が増えたときに再び壊れます。

batch()を分割すると、分割したすべてのbatchをまたぐ原子性は失われます。インポート処理では事前検証を完了してから反映し、途中失敗時にどこまで反映されたか追跡できる結果を返す設計が必要です。

キャッシュは「公開データ」と「個人データ」を混ぜない

D1はクエリ数ではなく、実際に読み取った行数・書き込んだ行数が課金や無料枠の指標になります。インデックスのない全件走査を繰り返すと、レスポンスだけでなくrows_readも増えます。

そこで、APIを大きく2種類に分けました。

公開APIは資格、問題、教科書など、誰が取得しても同じレスポンスだけを返します。ユーザーの正答数などは別APIに分けました。

Honoのroute定義にはキャッシュポリシーをメタデータとして持たせ、共通ラッパーがCache-Controlと共有キャッシュを適用します。

export const getPublicQualificationsRoute = createRoute({
  method: "get",
  path: "/",
  [CONFIG.CACHE.KEY]: CONFIG.CACHE.ONE_DAY_PUBLIC_POLICY,
  // ...
});

ミドルウェア側では、次のルールを統一しています。

  • 本番以外では共有キャッシュを使わない
  • CookieまたはAuthorizationがあるリクエストは原則バイパス
  • 公開データにはstale-while-revalidateを付ける
  • ブラウザ内ではTanStack QueryのstaleTimeも用途別に設定

認証チャレンジやシステム設定のような小さなKey-ValueデータはWorkers KVへ置き、画像・PDF・音声はR2へ逃がしています。D1を万能ストレージとして使わないことも、行読み取りを抑えるポイントでした。

R2の音声配信はRange Requestまで対応した

音声ファイルをR2に置くだけなら簡単ですが、ブラウザのシークやモバイル再生ではRangeリクエストが送られます。常にMP3全体を200 OKで返すと、途中再生やシークの挙動が不安定になります。

そこで公開メディアルートでは、Rangeヘッダーを解釈してR2の該当範囲だけを取得し、206 Partial Contentを返しています。

const object = await env.ASSETS_PUBLIC.get(key, {
  range: { offset: start, length: end - start + 1 },
});

headers.set("accept-ranges", "bytes");
headers.set("content-range", `bytes ${start}-${end}/${totalLength}`);
headers.set("content-length", String(end - start + 1));

return c.body(object.body, 206, Object.fromEntries(headers));

音声プレイリストでは、既存MP3を再エンコードせず、HLSのマニフェストとタイムスタンプ付きセグメントをWorkerで組み立てています。重い音声変換をリクエスト時に行わず、R2のオブジェクトをストリームする設計です。

失敗2:ログイン成功後のAPIが401になる

開発途中で、ログインAPIは200を返すのに、その直後のユーザーAPIが401になる問題が発生しました。

原因は認証ロジックそのものではなく、Next.jsとHono Workerを別オリジンで動かした際の境界でした。

  • 環境変数に/apiが付く場合と付かない場合があった
  • 相対URLのfetch("/api/...")が、意図しないホストへ送られた
  • クロスオリジンのリクエストにCookieが付いていなかった
  • CORS側も資格情報付きリクエストを明示的に許可する必要があった

APIベースURLは、ブラウザとSSRで参照順を分け、/apiを一度だけ付けるよう正規化しました。

const getClientUrl = () => {
  const raw = typeof window === "undefined"
    ? process.env.INTERNAL_API_URL
      || process.env.NEXT_PUBLIC_API_URL
      || process.env.EXPECTED_ORIGIN
    : process.env.NEXT_PUBLIC_API_URL;

  if (!raw) {
    if (typeof window !== "undefined") return "/api/";
    throw new Error("API base URL is not configured");
  }

  return raw.endsWith("/api") || raw.endsWith("/api/")
    ? raw.replace(/\/$/, "")
    : `${raw.replace(/\/$/, "")}/api`;
};

Honoクライアントは、すべてのリクエストでCookieを送ります。

export const client = hc(clientUrl, {
  fetch: (input, init) => fetch(input, {
    ...init,
    credentials: "include",
  }),
});

Worker側では、許可するOriginを列挙し、credentials: trueを設定します。

app.use("*", cors({
  origin: (origin, c) =>
    getAllowedOrigins(c).includes(origin) ? origin : null,
  credentials: true,
}));

この問題で重要だったのは、401を見てすぐJWT検証を疑わないことでした。ブラウザのNetworkタブで、実際の送信先、CookieSet-CookieAccess-Control-Allow-Originを順番に確認すると、認証処理へ到達する前にCookieが消えていると分かります。

失敗3:Honoの型を直接共有したら型チェックが肥大化した

Hono RPCの魅力は、サーバーのroute型をhc<AppType>()へ渡し、フロントでパスや入出力の補完を得られることです。

当初はAPI全体の型をNext.jsへ直接importしていました。しかし、この規模になるとWeb側の型チェックがserver実装、D1 binding、Cloudflare固有型までたどり、依存グラフが重くなりました。型の循環や、Webには不要なサーバー型の解決エラーも増えます。

現在は、Honoクライアントには呼び出し形だけを持つ軽量な型を渡し、レスポンスの具体型は各featureのAPI層でZodスキーマまたは明示型へ正規化しています。

export type RpcCall = (input?: unknown) => Promise<Response>;

export type RpcEndpoint = {
  $get: RpcCall;
  $post: RpcCall;
  $put: RpcCall;
  $patch: RpcCall;
  $delete: RpcCall;
};

export type RpcNode = RpcEndpoint & {
  [segment: string]: RpcNode;
};

export const client = hc(clientUrl, { fetch: credentialedFetch })
  as unknown as RpcNode;

これは「Hono RPCの完全なend-to-end型推論」を一部手放す判断です。その代わり、次を得ました。

  • Webの型チェックがserver実装全体を巻き込まない
  • feature単位でAPIレスポンスの正規化場所が明確になる
  • Cloudflare固有型をWebへ持ち込まずに済む

理想は、route契約だけを独立packageへ抽出し、そこからサーバーとクライアントの両方を生成することです。現在の軽量RpcNodeは最終形ではなく、開発速度と型チェック安定性を優先した中間解です。

成功した構成だけでなく、どの型安全性を残し、どこを境界で切ったかを明示しておくことが、モノレポでは重要だと感じました。

AIエージェント開発で速くなったこと、壊れたこと

このプロジェクトでは、Codexを含むAIエージェントを実装、リファクタリング、テスト作成、データ整備に利用しました。画面数と管理機能が多いため、開発速度への効果は大きかったです。

一方、指示が弱いと次の問題も実際に起きました。

  • 似たZodスキーマを画面ごとに再定義する
  • HonoのhandlerへDB処理とレスポンス整形が集中する
  • 同じデータを取得するAPIを検索条件ごとに増やす
  • モックの文字列IDを本番スキーマへ持ち込む
  • UIが必要とする以上の列・関連データを取得する

そこで、「AIに任せる」ではなく、変更可能な範囲を機械的に狭めるガードレールを追加しました。

  • 内部IDはD1のintegerで統一し、UUID/textへ変えない
  • packages/schemaをAPI境界のSSOTにする
  • route、service、schemaの責務を分ける
  • feature境界をESLintのカスタムルールで検査する
  • TanStack Queryのquery keyをfactory化する
  • new Date()の直書きを禁止し、JST処理を共通utilityへ寄せる
  • TypeScript、Vitest、Playwright、Storybookで変更を検証する
  • D1マイグレーションでテーブル再作成が発生しないか確認する

AIエージェントは実装量を増やすのが得意です。だからこそ、採用するコード量より、削除・統合・境界の検査に人間の判断を使う必要がありました。

月額ほぼ0円の実態

現在の利用量は無料枠内に収まっているため、インフラの固定費はほぼ0円で、主な固定費は独自ドメインです。

2026年8月時点の主な無料枠は以下です。

サービス 主な無料枠 このアプリでの用途
Cloudflare Pages 静的アセットのリクエストは無料・無制限、Freeは月500ビルド Next.jsフロントエンド
Workers 1日10万リクエスト、1リクエスト10ms CPU Hono API
D1 1日500万行read、10万行write、合計5GB Master/User/History DB
Workers KV 1日10万read、1,000 write、1GB 認証チャレンジ、設定キャッシュ
R2 Standard 月10GB-month、Class A 100万回、Class B 1,000万回 画像、PDF、MP3

注意点は、無料枠の「リクエスト数」だけを見ないことです。

  • D1は返却行数ではなく、スキャンしたrows_readが効く
  • Workers FreeはCPU時間が短いため、重い変換処理をリクエスト内で行わない
  • R2は転送料が無料でも、保存量と操作回数は計測される
  • Pages FunctionsはWorkersの割り当てとして数えられる

このため、インデックス、共有キャッシュ、R2への分離、非同期Queueがコスト設計と性能設計の両方に効いてきます。

「ずっと必ず0円」ではなく、現在のアクセス規模なら固定費を抑えられ、成長時にはどの指標が先に上限へ届くか把握できる構成と考えています。

今後改善したい点

実運用を始めて、次の課題も見えています。

  • JSON集約した学習履歴の同時更新対策
  • route契約だけを切り出した、軽量で厳密なRPC型共有
  • D1のrows_readを使ったクエリ・インデックスの継続監視
  • 分割batchが途中失敗した場合の再開可能なインポート
  • Queue処理の冪等性と重複実行ガード
  • Preview環境でのCloudflare実環境E2E

個人開発では、最初から巨大なスケールへ最適化するより、「どこが限界になり、どう拡張するか」をコードとドキュメントに残す方が現実的でした。

まとめ

Cloudflare中心の構成で特に効果があったのは、単に無料枠が大きいことではありません。

  • 公開データ、ユーザーデータ、履歴データを用途で分けられる
  • D1、KV、R2、QueuesをWorker bindingとして扱える
  • API、キャッシュ、ストレージの責務を一つのエコシステムで整理できる
  • 小さく始め、必要な部分だけを分割・拡張できる

一方で、D1のバインド変数上限、batchの大きさ、クロスオリジンCookie、モノレポの型依存など、ローカルの小さなデータでは見えない問題もありました。

今回一番大きかった学びは、サービスを速く作ることと、制限を無視して作ることは別だという点です。プラットフォームの制約を定数・境界・テストへ落とし込むことで、個人開発でも運用可能な形に近づけられました。

実際のサービスはこちらです。

公式ドキュメント

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?