Next.js静的エクスポートサイトにサーバーなしでサイト内検索を実装した記録
このブログは output: 'export' の静的エクスポート構成(サーバー機能なし・API Routesも使えない)で運用しています。記事数が40本を超えたあたりから「前にAWSコストの記事どこだっけ」と自分で探しにくくなり、サイト内検索を実装しました。サーバーがない制約の中でどう作ったか、実際に運用して分かったことまで記録します。
検討した3つの選択肢
サーバーなしでサイト内検索を実現する方法はいくつかありますが、それぞれ見送った理由があります。
| 選択肢 | 内容 | 採用しなかった理由 |
|---|---|---|
| Algolia等のSaaS | 検索APIをホスティングしてもらう | 無料枠はあるが、アカウント登録・APIキー管理の手間が個人ブログの規模に見合わない |
| Pagefind | 静的サイト向けの検索ツール定番 | 依存パッケージの追加とビルド後処理の組み込みが必要。日本語はCJKのセグメンテーション調整も要る |
| 自前JSONインデックス | ビルド時に検索用JSONを生成し、クライアント側で絞り込む | 採用。追加費用ゼロ・外部登録不要・実装がシンプル |
結局、一番シンプルな「自前でインデックスを作ってブラウザ側で絞り込む」方式に落ち着きました。
日本語検索の設計判断: トークナイズをやめる
検索エンジンを自作するとなると「形態素解析でトークナイズして転置インデックスを作る」のが王道に見えますが、今回はあえてそれをやめました。理由は、日本語には英語のような単語区切りのスペースがなく、トークナイズの精度を追求すると実装が一気に重くなるからです。
代わりに採用したのは、NFKC正規化+小文字化した文字列に対する部分一致(スペース区切りのAND検索)です。全角英数字と半角英数字の表記ゆれ(AWSとAWS等)をNFKC正規化で吸収し、あとは単純な indexOf ベースの部分一致で十分実用に足ります。記事数十本規模のブログで、厳密な形態素解析をする理由はありませんでした。
実装: ビルド時にインデックスを作ってクライアントで絞り込む
全体の流れは次の通りです。
scripts/generate-search-index.mjs が npm run build(とnpm run dev)のprebuild/predevフックで自動実行され、全記事のMarkdownをプレーンテキスト化してJSONにまとめます。インラインSVGやコードフェンスの記号、テーブルの罫線などは検索ノイズになるので除去しつつ、コードの中身(コマンド名等)は検索対象として残すのがポイントです。生成物はコミットせず、ビルドのたびに再生成します。
クライアント側(SearchClient.jsx)はこのJSONをfetchして、入力語をスペース区切りでAND検索します。スコアはフィールドごとに重み付けしていて、タイトル一致を本文一致より圧倒的に優先しています。
| フィールド | 重み |
|---|---|
| タイトル | 10 |
| タグ | 8 |
| 説明文・カテゴリ | 4 |
| 本文(出現1回あたり、最大5回まで) | 1 |
本文の出現回数に上限を設けているのは、長文記事がキーワードを含むだけで不当に上位表示されるのを防ぐためです。検索結果の抜粋はヒット位置の前後60文字を切り出し、<mark>タグでハイライトしています。
部分一致を選んだことで生じるトレードオフも実際に体感しました。たとえば「クラウド」で検索すると「クラウドフロント(CloudFront)」を含む記事もヒットします。厳密な単語単位のマッチではないため、短い検索語だと意図しない記事まで拾ってしまうことがあります。とはいえタイトル一致を最優先するスコア設計にしているため、実用上は上位に本当に探している記事が来ることがほとんどで、この程度のノイズは許容範囲だと判断しています。
ハマったところ
useSearchParamsを使うコンポーネントはSuspense境界が必須。付けないとビルドエラーになる- URL反映は
history.replaceStateを使う。router.replaceだと1文字入力するたびにページ全体の再レンダリングが走ってしまう - サイドバーの検索ボックスから
?q=付きで検索ページへ遷移してもコンポーネントは再マウントされない。そのままだとURLが変わっても入力欄に反映されないため、レンダー中に状態を同期するパターンが必要になる。useEffect内でsetStateすると連鎈的な再レンダリングを招くため避け、レンダー本体で「前回のURLクエリと違ったら同期する」形にした - 検索結果ページはクエリごとに内容が変わる薄いページになるため、
robots: { index: false, follow: true }にしてsitemapからも除外した。動的に中身が変わるページをインデックスさせると、SEO上「低品質ページ」と判定されるリスクがあるための対処
実際に運用して分かった索引サイズの誤算
生成スクリプトは実行時に自分でサイズを表示してくれるのですが、この表示に見落としがありました。
Search index generated: 40 posts, 246.4 KB
この246.4 KBという数値は、JavaScriptの文字列長(json.length、UTF-16コードユニット数)を1024で割っただけの値です。しかし実際にディスクへ書き出されるファイルはUTF-8エンコードのバイト列で、日本語の文字は1文字あたり3バイトになることが多いため、実ファイルサイズは568KB前後と表示値の2倍以上になっていました。
# 実バイトサイズを確認する場合
ls -la public/search-index.json
配信時は当然gzip/Brotli圧縮がかかるため(このブログはCloudflare Pagesなので自動)、実際の転送量は圧縮後で186KB程度に収まります。とはいえ、ビルドログの数値を鵜呑みにして「200KBちょっとだから軽い」と思い込んでいたら、実測の3割増しの転送量になっていたという話です。数値をビルドログの自己申告だけで判断せず、実ファイルサイズを直接確認する習慣の大切さを実感しました。
まとめ
外部サービスを使わず、ビルド時生成+クライアント側部分一致という一番シンプルな方式でも、記事数十本規模のブログなら十分実用的なサイト内検索になります。日本語サイトで自前検索を実装するなら、無理にトークナイズ精度を追わずNFKC正規化+部分一致から始めるのが費用対効果は高いはずです。同じ静的エクスポート構成でサイト内検索を検討している方の参考になれば幸いです。