検索検索ゥ!
前回の記事で検索機能はつけない!(血泣)とか言ってましたが、いれました。
元々ヘッダーにはAboutリンクがありましたが、まぁいらんページなので、検索アイコンに変更しました。
採用した構成
PageFindという検索ライブラリを使いました。
「Astro SSG 検索ライブラリ」で調べたら結構ヒットしたのですが、なかなか品質がよいみたいだったので採用しました。
Pagefindは大規模なサイトでも高速に動作し、帯域幅も最小限にすると謳っていて、HTMLファイルを読み込んでインデックスを作成、検索UIもデフォルトで提供される検索用ライブラリです。
通常のフレームワークのビルド成果物を対象にインデックスを生成するので、とても簡単に導入することができます。
検索エンジンとAstro連携を上記のライブラリを使いましたが、検索UIだけ新規で作成しました。
導入
1. パッケージを導入
npm install astro-pagefind2. Astroの設定にintegrationを追加
astro.config.mjs に pagefind() を追加します。
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import pagefind from "astro-pagefind";
export default defineConfig({
site: "https://example.com/",
integrations: [tailwind(), pagefind()],
});site は自分の公開URLに置き換えます。
3. ヘッダーを検索アイコン導線に変更
Aboutリンクを外し、検索アイコンから /search?q= へ遷移させます。
最初は検索バーおいてましたが、アイコンに変更してすっきりさせました。
---
const { title } = Astro.props;
---
<header class="bg-background border-b">
<div class="container mx-auto px-4 md:px-6 py-4 flex items-center justify-between gap-4">
<a href="/" class="flex items-center gap-2">
<span class="text-lg font-semibold">{title}</span>
</a>
<a
href="/search?q="
aria-label="検索ページへ"
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-gray-300 text-gray-700 transition hover:bg-gray-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-5 w-5"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</a>
</div>
</header>4. 検索ページを作成
src/pages/search.astro を作ります。/search?q=foo で初期検索が可能で、SSG環境で動作します。
---
import Layout from "@layouts/Layout.astro";
export const prerender = true;
const Meta = {
title: "検索",
description: "ブログ記事の検索ページ",
};
---
<Layout Meta={Meta}>
<section class="space-y-6 md:col-span-2">
<h1 class="text-3xl font-semibold">サイト内検索</h1>
<form action="/search" method="get" class="max-w-2xl">
<label for="search-input" class="sr-only">検索ワード</label>
<div class="flex gap-2">
<input
id="search-input"
name="q"
type="search"
placeholder="タイトル・本文で検索"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
検索
</button>
</div>
</form>
<p id="search-summary" class="text-sm text-gray-600">検索ワードを入力してください。</p>
<ul id="search-results" class="space-y-3"></ul>
</section>
<script>
const input = document.getElementById("search-input");
const summary = document.getElementById("search-summary");
const results = document.getElementById("search-results");
const query = new URLSearchParams(window.location.search).get("q")?.trim() || "";
if (!(input instanceof HTMLInputElement) || !summary || !results) {
throw new Error("Search UI initialization failed.");
}
input.value = query;
const clearResults = () => {
results.replaceChildren();
};
const renderSummary = (text) => {
summary.textContent = text;
};
const renderItem = (item) => {
const li = document.createElement("li");
li.className = "rounded-md border border-gray-200 p-4 hover:bg-gray-50 transition";
const link = document.createElement("a");
link.href = item.url;
link.className = "text-lg font-medium hover:underline";
link.textContent = item.meta?.title || item.url;
li.appendChild(link);
if (item.excerpt) {
const excerpt = document.createElement("p");
excerpt.className = "mt-2 text-sm text-gray-700";
excerpt.innerHTML = item.excerpt;
li.appendChild(excerpt);
}
results.appendChild(li);
};
const searchInBlog = async (term) => {
if (!term) {
clearResults();
renderSummary("検索ワードを入力してください。");
return;
}
renderSummary(`「${term}」を検索中...`);
clearResults();
const pagefindPath = `${import.meta.env.BASE_URL}pagefind/pagefind.js`;
const pagefind = await import(/* @vite-ignore */ pagefindPath);
await pagefind.options({ excerptLength: 25 });
const searched = await pagefind.search(term);
const allData = await Promise.all(searched.results.map((r) => r.data()));
// /blog 配下だけを表示
const blogOnly = allData.filter((item) => item.url.startsWith("/blog/"));
if (blogOnly.length === 0) {
renderSummary(`「${term}」に一致するブログ記事はありません。`);
return;
}
renderSummary(`「${term}」の検索結果: ${blogOnly.length}件(/blog配下のみ)`);
blogOnly.forEach(renderItem);
};
if (query) {
searchInBlog(query);
}
</script>
</Layout>5. インデックス対象も絞る
ここまでで実装完了ではあるんですが、デフォルトだとすべてのページが検索対象になるので、Categoryやページネーションまで検索結果にでてしまいます。
今回は /blog配下の個別ページのみ検索結果に表示させたかったので、インデックスの対象も絞り込みしました。
表示時のフィルタだけでなく、インデックス時にも除外したい場合は data-pagefind-ignore を使います。
---
const isBlogDetail = Astro.url.pathname.startsWith("/blog/");
---
<body data-pagefind-ignore={isBlogDetail ? undefined : true}>6. ビルド確認
astro buildをすると、Pagefindのインデックス生成のステップが増えますので、ビルド時間もその分長くなります。
私のブログはそこまで大量の記事を抱えてないので大丈夫ですが、膨大なコンテンツがあるサイトの場合はどうなるかわからぬ・・・
npm run buildハマりどころ
1) Viteの動的import解決エラー
import("/pagefind/pagefind.js") をそのまま書くと、ビルド時に解決エラーになることがあります。
対策:
import.meta.env.BASE_URLを使ってランタイムパス化/* @vite-ignore */を付与
完成

めっちゃ簡単で高速に検索エンジン導入できました💩
ざっとみた感じ日本語が弱そうですが、英単語に関しては文句のない精度です。
懸念
Pagefindはビルド時に静的HTMLをクロールしてインデックスを生成する仕組みなので、ISRなどのインクリメンタルな方式には対応してなさそうです。
もしISRに対応させるのであれば、Algolia / MeiliSearch などの外部検索サービスを使うか、Fuse.js などのクライアントサイド全文検索を使う必要がありそう??
まーいまの私のブログには関係ないがな。ガハハ。