FAQチャットウィジェットを自作する方法【Next.js×Cloudflare D1】


はじめに

SaaSにチャットサポートを導入しようとすると、IntercomやZendeskといった外部サービスが真っ先に候補に挙がります。ただ、月額$55〜$74という費用は、ユーザーがまだ数十人という個人開発フェーズでは現実的ではありません。

この記事では、Next.js + Cloudflare Workers + D1 を使ったFAQチャットウィジェットの設計と実装の考え方を解説します。外部サービスなしで、コストほぼゼロ・データ外部送信なし・Core Web Vitalsへの影響なしを実現する構成です。


完成イメージと設計方針

動作の仕組み

ユーザーが入力
    ↓
クライアント側でキーワードマッチング(APIコールなし)
    ↓                      ↓
マッチあり               マッチなし
→ 即座に回答表示         → バックグラウンドでログ送信
                              ↓
                         Cloudflare Workers → D1に保存
                              ↓
                         管理画面でログ確認 → FAQを改善

設計の核心は「キーワードマッチングはクライアント完結、未マッチのみサーバーへ」です。マッチした場合はAPIコールが発生しないため、レイテンシがゼロになります。

なぜAIではなくキーワードマッチングか:Claude APIやOpenAI APIを使えば回答品質は上がりますが、コストが積み重なり、回答にブレが生じます。SaaSのFAQは「常に同じ正確な回答を返す」ことが重要なので、キーワードマッチングの方が適しています。AI統合は未マッチが多くなってきた段階で段階的に導入するのが現実的です。


ステップ1:FAQルールの設計

FAQルールはTypeScriptのオブジェクトとして管理します。CMSは不要です。キーワードの配列と、対応する回答キーをマッピングするシンプルな構造です。

※以下はサンプルコードです。実際のキーワードや回答は自身のサービスに合わせて定義してください。

// faq-rules.ts(サンプル)
type FAQRule = {
  keywords: string[]   // マッチさせるキーワード一覧
  answerKey: string    // 回答を引き出すキー
}

const FAQ_RULES_JA: FAQRule[] = [
  {
    keywords: ['料金', '値段', 'いくら', 'プラン'],
    answerKey: 'pricing',
  },
  {
    keywords: ['解約', 'キャンセル', '退会'],
    answerKey: 'cancel',
  },
  // 実際のサービスに合わせてルールを追加する
]

回答テキストは別ファイルで answerKey をキーにした辞書として管理します。ルールと回答を分離することで、文言の修正がしやすくなります。日本語と英語でルールセットを分けることで、それぞれの言語に最適化したキーワードを設定できます。


ステップ2:マッチングロジックの設計

マッチングのコアは非常にシンプルです。入力文字列をノーマライズ(正規化)してから、キーワードと部分一致するか確認します。

ノーマライズとは、大文字/小文字の違いや余分なスペースを除去して比較しやすい形に変換することです。「料金」と「 料金 」が同じ意味で扱えるようになります。

※以下は処理の流れを示すサンプルです。

// matcher.ts(サンプル)
function findAnswer(input: string, rules: FAQRule[]): string | null {
  const normalized = input.toLowerCase().replace(/\s+/g, '')

  const matched = rules.find((rule) =>
    rule.keywords.some((kw) => normalized.includes(kw.toLowerCase()))
  )

  return matched?.answerKey ?? null
}

クライアント側で処理するメリットは3点です。APIコールが不要なのでレイテンシがゼロ、サーバーへの負荷もゼロ、オフライン環境でも動作します。ルールが100件程度であればJSバンドルサイズへの影響もほぼ無視できます。


ステップ3:未マッチログをD1に保存する

マッチしなかった入力はCloudflare WorkersのAPIでD1に記録します。このログが「FAQを育てる」ための最重要データになります。

D1のスキーマ設計

※以下はスキーマ設計のサンプルです。

-- サンプルスキーマ
CREATE TABLE IF NOT EXISTS chat_unmatched_logs (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_input TEXT    NOT NULL,
  locale     TEXT    NOT NULL DEFAULT 'ja',
  page_path  TEXT,
  created_at TEXT    NOT NULL DEFAULT (datetime('now'))
);

page_path を記録しておく理由は、「料金ページで料金以外の質問が多い」といったページ単位の傾向を把握するためです。どのページで何が未マッチになっているかがわかると、FAQ改善の優先度が立てやすくなります。

未マッチ時のログ送信

未マッチが発生したとき、クライアントからAPIへログを送信します。ポイントは fire-and-forget(送りっぱなし)方式にすることです。ログ送信の失敗をユーザーのUXに影響させないために、エラーは握りつぶします。

※以下はfetch処理のサンプルです。

// 未マッチ時のログ送信(サンプル)
fetch('/api/chat-log', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ user_input: input, locale, page_path: pathname }),
}).catch(() => {}) // エラーは無視。ログ失敗はUXに影響させない

Cron Triggerで30日後に自動削除

Cron Triggerとは、定期的に処理を自動実行するCloudflareの機能です。個人情報に近いデータを無期限に保持しないために、30日で自動削除する仕組みを入れます。wrangler.toml でスケジュールを設定し、Workerの scheduled ハンドラで削除クエリを実行します。


ステップ4:UIの設計ポイント

Reactコンポーネントとして実装する際に、効果的だったUI上の工夫を3つ紹介します。

クイックアクションボタン

テキスト入力欄の上に「料金について」「解約方法」といったボタンを並べます。自由入力よりもボタンタップの方がユーザーの心理的ハードルが低く、マッチ率が大幅に上がります。未マッチログが減る最も効果的な施策です。

セッション維持

ウィジェットの開閉状態を sessionStorage で保持します。ページ遷移してもチャット履歴と開閉状態が保たれるため、会話が途切れません。

// セッション維持のサンプル
const [isOpen, setIsOpen] = useState(() => {
  return sessionStorage.getItem('chat_open') === 'true'
})

管理画面では非表示

/admin 配下のパスではウィジェットを非表示にします。管理者が自分自身のチャットを誤操作するのを防ぐシンプルな処置です。Next.jsの usePathname() フックで現在のパスを取得して判定します。


ステップ5:ログを活用してFAQを育てる

未マッチログを管理画面で一覧表示するAPIを用意します。同じ質問が複数回未マッチになっているものを優先的にFAQルールに追加すると、効率的に改善できます。

実際にログを見ていると、想定外のキーワードが次々と見つかります。「領収書」「インボイス」「重い」「API連携」といったワードは、実際のログからでないと気づけないものです。「ありがとう」「助かった」といった感謝の言葉が未マッチとして溜まることもあり、そういった入力に対してポジティブな定型文を返すルールを追加するのも効果的です。

ログを見る→ルールに追加する→未マッチ率を下げる、このループがチャットウィジェットの本当の価値です。


コスト比較

手段月額コスト主な制約
Intercom(スタータープラン)$74〜人数課金で増加
Zendesk(Suiteチーム)$55〜エージェント数課金
Crisp(Pro)$25〜無料枠はブランド表示あり
自作(Cloudflare Workers + D1)$0〜数十円Workers無料枠:10万req/日、D1無料枠:5GB

個人開発の初期フェーズであれば、実質無料で運用できます。サイトのSEOやCore Web Vitalsのスコアが気になる方は、CodeQuest SEO_CHECKで診断してみてください。外部スクリプトの読み込みによるパフォーマンスへの影響も確認できます。


まとめ

自作FAQチャットウィジェットの設計ポイントをまとめます。

  • マッチングはクライアント完結:APIコールなしでレイテンシゼロ。Core Web Vitalsに影響しない
  • 未マッチのみD1に記録:fire-and-forget方式でUXに影響させない
  • クイックアクションボタンを最優先:自由入力より先に設置することでマッチ率が上がる
  • ログを育てる仕組みが本質:管理画面でログを確認→FAQルールに追加→未マッチ率を下げるループが価値を生む

外部サービスへの依存をなくすことで、データのコントロール・コスト・パフォーマンスのすべてで有利になります。将来的にAIを導入する場合も、「未マッチ時だけLLMにフォールバック」という形でこの基盤をそのまま活用できます。


よくある質問

Q. FAQルールが増えてきたらどう管理すればいいですか?

ルールが100件程度まではTypeScriptファイルでの管理で十分です。それ以上になってきたらJSONファイルに切り出すか、D1に移してAPIで取得する構成にするとよいです。WorkersのKV(Key-Valueストア)にキャッシュすることで、D1へのクエリ回数も減らせます。

Q. 管理画面のアクセス制限はどう実装しますか?

Cloudflare Access(無料プランあり)をAPIのサブドメインに設定するのが最もシンプルです。Googleアカウント認証と組み合わせることで、特定のメールアドレスのみアクセスを許可できます。

Q. 将来的にAIを組み込むとしたらどんな構成になりますか?

「キーワードマッチングで回答できなかった場合だけLLM APIにフォールバックする」ハイブリッド構成が現実的です。全件をAIで処理するよりAPIコストを大幅に抑えられ、ルールが明確な質問は常に安定した回答を返せます。まずキーワードマッチングで基盤を作り、未マッチ率が高止まりしてきたタイミングでAIを追加するのが順序として自然です。