概要
Takazudo Modularのサイト検索をAlgoliaからMiniSearchに移行した。外部サービスへの依存をなくして、ビルド時にインデックスをJSONとして生成し、Netlify Functionで検索するというセルフホスト構成に変えた。そのまとめ。

背景
Takazudo Modularでは記事の全文検索にAlgoliaを使っていた。Algoliaは高機能なのだが、以下の点が気になっていた。
- APIキーの管理が必要(
ALGOLIA_ADMIN_KEY,NEXT_PUBLIC_ALGOLIA_APP_ID,NEXT_PUBLIC_ALGOLIA_SEARCH_KEY,SKIP_ALGOLIA_INDEXINGなど環境変数が多い) - ビルド時にAlgoliaへインデックスを送信する処理がCIを複雑にしていた
- 無料枠の制限を意識する必要がある
- サイト規模的に、ここまでの検索エンジンは不要
サイトの記事数や更新頻度を考えると、セルフホストの全文検索で十分という判断になった。
MiniSearch の選定
軽量な全文検索ライブラリとしてMiniSearchを採用した。npmパッケージminisearchをインストールするだけで使える。ブラウザでもNode.jsでも動作し、JSONデータからインデックスを構築できる。
ビルド時のインデックス生成
Next.jsのビルドプロセスでsearch-data.jsonを生成する。全記事のtitle、description、tags、excerptなどを含むJSONファイルで、これが検索のデータソースになる。
サーバーサイド検索: 本番環境
本番環境では、Netlify Function(netlify/functions/search.ts)として検索APIを実装した。search-data.jsonをesbuildでバンドル時にimportして、MiniSearchインスタンスをモジュールレベルで初期化する。Warm invocationで再利用される。
// netlify/functions/search.ts
import searchData from "./search-data.json";
const miniSearch = new MiniSearch<SearchDocument>({
fields: config.fields,
storeFields: config.storeFields,
searchOptions: config.searchOptions,
});
miniSearch.addAll(documents);APIはGET /.netlify/functions/search?q=<query>&locale=ja&excerpt_length=200で呼べる。
クライアントサイド検索: 開発環境
開発時はNetlify Functionが動かないので、use-search.tsフックがクライアントサイドで直接MiniSearchを使う。/search-index.jsonをfetchしてMiniSearchインスタンスを構築する。本番では/api/searchエンドポイント(Netlify Function)を呼ぶ。
async function performSearch(query: string, locale: "ja" | "en") {
if (isDev) {
const ms = await loadDevIndex(); // クライアントサイド MiniSearch
const results = ms.search(query).map(/* ... */);
return applyLocaleBoost(results, locale);
}
// 本番: Netlify Function を呼ぶ
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}&locale=${locale}`,
);
return (await res.json()).results;
}開発環境と本番環境で検索の実行方法が異なるが、呼び出し側からは同じインターフェースで使える。
ロケール対応のスコアブースト
サイトには日本語ページと英語ページが混在している。検索時にロケールに応じたスコアブーストを実装して、日本語検索ページでは日本語記事のスコアを3倍に、英語検索ページでは英語記事のスコアを3倍にする。
// src/utils/locale-boost.ts
const LOCALE_BOOST_FACTOR = 3;
export function applyLocaleBoost<T extends { id: string; score: number }>(
results: T[],
locale: string,
): T[] {
return results
.map((r) => {
const isEnArticle = r.id.endsWith(".en");
const isMatchingLocale = locale === "en" ? isEnArticle : !isEnArticle;
const adjustedScore = isMatchingLocale
? r.score * LOCALE_BOOST_FACTOR
: r.score;
return { ...r, score: adjustedScore };
})
.sort((a, b) => b.score - a.score);
}英語記事はIDの末尾が.enで判別できるという規約を利用している。シンプルだが実用上は十分。
esbuild の制約と対処
Netlify Functionsはesbuildでバンドルされるのだが、../../src/utils/locale-boostのようなパスからのimportが解決できない問題に遭遇した。Netlify Functionのバンドルプロセスがプロジェクトルートのsrc/ディレクトリを参照できない。
対処として、Netlify Function内にapplyLocaleBoost関数をインライン化した。共有ユーティリティはsrc/utils/locale-boost.tsに置きつつ、Netlify Function側は独立したコピーを持つ形。DRY原則には反するが、esbuildの制約上やむを得ない。
検索結果のハイライトとexcerpt
検索キーワードにマッチする部分を<mark>タグでハイライト表示する。excerptは検索キーワードの周辺を中心に200文字程度を切り出す。HTMLエスケープも適切に処理している。
ローディングUI
検索中のローディング表示として、サイトで使っているハムスターアニメーション(CSSアニメーション)を小さいサイズ(200px)で表示するようにした。元々はページ遷移時のオーバーレイで使っていたものを、コンポーネントとして切り出して再利用した。
キーワードログ
検索キーワードをNetlify Blobs(keyword-logsストア)に非同期で記録するようにした。ゼロ結果の検索も記録して、改善のヒントにする。
Algolia の完全除去
MiniSearch移行完了後、Algolia関連のコード・設定・ドキュメントをすべて削除した。
src/utils/algolia-queries.mjs(検索インデックス設定)を削除AlgoliaHit型定義を削除- CIワークフローからAlgolia環境変数を除去(
main-deploy.yml、preview-next-deploy.yml) .env.exampleからAlgolia設定を除去- ドキュメント類をMiniSearchに更新(CLAUDE.md、workflows README、4つのドキュメントファイル)
環境変数が4つ減って、CIの設定がシンプルになった。
アーキテクチャまとめ
全体のデータフローはこうなっている。
ビルド → JSON → サーバーレス関数という直線的なデータフローで、全体の見通しが良い。
パフォーマンス: インデックスJSONはユーザーにダウンロードされない
この構成で重要なのは、本番環境では検索インデックスのJSON(search-data.json)がユーザーのブラウザにダウンロードされないという点。検索インデックスはNetlify Functionにesbuildでバンドルされてサーバーサイドに留まり、ユーザーには検索結果のレスポンス(数KB程度)だけが返る。
現時点でインデックスJSONのサイズは約388KB、286ドキュメント。gzip圧縮しても100〜150KB程度になる。仮にこれをクライアントサイドに全件ダウンロードする方式にしていたら、検索を使わないユーザーにも毎回このコストがかかることになる。記事が増えて600件になればJSONは800KB超になりうるので、クライアントサイド方式ではスケールしない。
サーバーサイドAPI方式なら、インデックスはNetlify Functionのメモリ上に保持され、warm invocationで再利用される。ユーザーが増えてもインデックスの転送コストはゼロ。MiniSearchのインメモリ検索は高速なので、レスポンスも速い。
将来的にドキュメント数が数千件規模になった場合、コールドスタート時のインデックス構築時間が気になる可能性はある。その場合はMiniSearchのシリアライズ済みインデックスを事前構築する方法がある。ただし現在の規模では全く問題ない。
余談
検索という機能は外部サービスに頼りがちだが、サイト規模によってはMiniSearch + サーバーレス関数で十分実用的な構成が作れる。Algoliaのような高機能な検索は大規模サイトには必要だろうが、個人や小規模サイトならセルフホストで事足りる。環境変数の管理コストが減るだけでも地味にメリットがある。