Astroの静的ブログに検索ボックスを外部サービスなしで実装する

Astroの静的ブログに検索ボックスを外部サービスなしで実装する 使い方・設定
Astroの静的サイトに外部サービスなしで検索ボックスを後付けする構成のイメージ

Astro で作った静的ブログに検索機能を付けたい。でも記事は数十本で、Algolia のような外部検索サービスを契約するほどではない。サーバーも持ちたくない。そういうときは、ビルド時に記事データを JSON として書き出し、ブラウザ側でリアルタイムにフィルタするだけで十分な検索が作れる。

この記事では、Astro の静的サイト(Cloudflare Pages で実質無料で動かす構成を前提)に、外部サービスもサーバーも使わず検索ボックスを後付けする方法を解説する。ポイントは、JavaScript が無効でも最低限動く「漸進的強化(Progressive Enhancement)」で組むことだ。

📋 この記事でわかること

  • 外部検索サービスを使わず Astro 静的サイトに検索を付ける全体設計
  • トップページの検索フォームを <form method="get"> で組んで JS なしでも動かす方法
  • ビルド時に記事データをクライアントへ渡す(define:vars)手順
  • タイトル・概要・カテゴリを対象にしたリアルタイムフィルタの実装
  • URL クエリパラメータと検索状態を同期して共有可能にする方法
  • 該当 0 件の表示や結果件数の出し方

全体設計:2ページで役割を分ける

検索を2つのページに分けて考えると実装がすっきりする。

  • トップページ:検索ボックスを置くが、ここでは絞り込みをしない。入力された語を ?q=... として記事一覧ページへ渡すだけ
  • 記事一覧ページ?q= を受け取り、ビルド時に埋め込んだ記事データをクライアントでフィルタする

この分担にすると、トップの検索ボックスは単なる「一覧ページへの入口」になり、実際の検索ロジックは一覧ページ1か所に集約できる。

Step 1: トップの検索フォームを JS なしで動かす

まずトップページの検索ボックスを、ただの <div> ではなく本物の <form> にする。


---
// src/pages/index.astro
---
<form class="hero-search" role="search" action="/posts/" method="get">
  <input
    class="hero-search-input"
    type="search"
    name="q"
    placeholder="「ご祝儀 相場」「保育園 申込み」"
    autocomplete="off"
  />
</form>

ポイントは action="/posts/"method="get" だ。これだけで、ユーザーが語を入力して Enter を押すと /posts/?q=入力語 に遷移する。JavaScript を1行も書かなくても、検索語を一覧ページに渡すところまでは動く

name="q" がそのままクエリパラメータのキーになる。後段の一覧ページはこの q を読む契約にしておく。

JSなしでもformのGET送信で検索語を一覧ページへ渡せる漸進的強化のイメージ

Step 2: ビルド時に記事データをクライアントへ渡す

記事一覧ページでは、まず Astro のコンテンツコレクションから記事を取得する。検索でフィルタするのに必要なフィールドだけを、軽量なオブジェクトに詰め直しておく。


---
// src/pages/posts/index.astro
import { getCollection } from "astro:content";

const posts = (await getCollection("posts", ({ data }) => !data.draft)).sort(
  (a, b) => b.data.publishedAt.localeCompare(a.data.publishedAt),
);

// クライアントへ渡す軽量データ(本文は含めない)
const postsData = posts.map((p) => ({
  id: p.id,
  title: p.data.title,
  description: p.data.description,
  category: p.data.category,
  publishedAt: p.data.publishedAt,
  heroImage: p.data.heroImage ?? null,
}));
---

本文(Markdown 全文)まで渡すとページが重くなるので、検索対象にしたいタイトル・概要・カテゴリだけに絞る。数十本規模ならこの JSON は数 KB に収まる。

このデータをクライアントの <script> に渡すのが define:vars だ。


<ul class="post-list" id="postList">
  {posts.map((post) => (
    <li>
      <a href={`/posts/${post.id}/`}>{post.data.title}</a>
    </li>
  ))}
</ul>
<p class="muted no-results" id="noResults" style="display:none">
  該当する記事が見つかりませんでした。
</p>

<script define:vars={{ postsData }}>
  // ここで postsData がそのまま使える
  console.log(`${postsData.length} 件の記事をインデックス`);
</script>

define:vars={{ postsData }} と書くと、サーバー側(ビルド時)の postsData が JSON にシリアライズされ、クライアントの <script> から参照できる。HTML 側のリスト(<li>)とクライアント側のデータ配列が同じ順序になるよう、どちらも同じ posts から生成しているのがポイントだ。

ビルド時の記事データをdefine:varsでクライアントスクリプトへ渡すイメージ

Step 3: リアルタイムにフィルタする

あとはクライアント側で、入力に応じてリストの表示・非表示を切り替えるだけだ。


<script define:vars={{ postsData }}>
  const input = document.getElementById("searchInput");
  const list = document.getElementById("postList");
  const countEl = document.getElementById("resultCount");
  const noResults = document.getElementById("noResults");
  const items = list ? Array.from(list.children) : [];

  function applyQuery(q) {
    const kw = q.trim().toLowerCase();
    let visible = 0;
    items.forEach((li, i) => {
      const p = postsData[i];
      const hit = !kw ||
        p.title.toLowerCase().includes(kw) ||
        p.description.toLowerCase().includes(kw) ||
        p.category.toLowerCase().includes(kw);
      li.style.display = hit ? "" : "none";
      if (hit) visible++;
    });
    countEl.textContent = kw
      ? `${visible} 件 / 合計 ${postsData.length} 本`
      : `合計 ${postsData.length} 本`;
    noResults.style.display = visible === 0 ? "" : "none";
  }
</script>

items(DOM の <li>)と postsData(データ配列)をインデックスで対応づけ、ヒットしない行は display: none で隠す。toLowerCase() で大文字小文字を無視し、タイトル・概要・カテゴリのいずれかに部分一致すれば表示する。件数表示と「0 件」メッセージもここで更新する。

DOM を作り直さず display を切り替えるだけなので、数百件程度までは体感で遅延を感じない。

入力に応じてリストをリアルタイムに絞り込みURLパラメータも更新するイメージ

Step 4: URL と検索状態を同期する

トップから /posts/?q=ご祝儀 で飛んできたとき、その語で初期フィルタがかかってほしい。また、絞り込んだ状態の URL をそのまま共有できると親切だ。URLSearchParamshistory.replaceState でこれを実現する。


<script define:vars={{ postsData }}>
  // ...applyQuery は前掲...

  // URL の ?q= から初期化(トップからの遷移に対応)
  const params = new URLSearchParams(location.search);
  const initialQ = params.get("q") || "";
  if (input) {
    input.value = initialQ;
    applyQuery(initialQ);

    input.addEventListener("input", (e) => {
      const q = e.target.value;
      const url = new URL(location.href);
      if (q) url.searchParams.set("q", q);
      else url.searchParams.delete("q");
      // ページ遷移せず URL だけ書き換える
      history.replaceState(null, "", url);
      applyQuery(q);
    });
  }

  // フォーム送信は抑止(フィルタはリアルタイムなので遷移不要)
  const form = document.getElementById("searchForm");
  if (form) form.addEventListener("submit", (e) => e.preventDefault());
</script>

history.replaceState を使うと、ページをリロードせずにアドレスバーの URL だけを更新できる。これで「絞り込んだ状態の URL」をコピーして共有でき、リロードしても同じ結果が再現する。

一覧ページ自身のフォーム送信は preventDefault() で止める。入力ごとにリアルタイムでフィルタしているので、Enter での再遷移は不要だからだ。なお JS が無効な環境では preventDefault が効かず、method="get" のフォームとして素直に再読み込みされるだけで、壊れはしない。

ハマりやすいポイント

DOM の順序とデータ配列の順序を必ず一致させる

items[i]postsData[i] をインデックスで対応づけているため、リストの描画順とデータ配列の順序がずれると、別の記事を隠してしまう。どちらも同じ posts(同じソート結果)から生成することで一致を保証する。別々にソートし直したりしないこと。

検索対象に本文を含めるかは要件次第

今回はタイトル・概要・カテゴリのみを対象にした。本文全文を検索したい場合は postsData に本文を含める必要があるが、ページの転送量が一気に増える。全文検索が必要になったら、それは外部サービス(Pagefind など静的サイト向けの全文検索ライブラリ)を検討するサインだ。

define:vars に渡せるのはシリアライズ可能な値だけ

define:vars は値を JSON 化してクライアントへ渡す。関数や Date オブジェクト、循環参照を含むオブジェクトは渡せない。日付は文字列(publishedAt を ISO 文字列)にしてから渡す。

DOM順とデータ配列の一致・本文は任意・シリアライズ可能な値のみといった注意点のイメージ

よくある質問

Q. 記事が増えても大丈夫ですか?

A. 数百本までは問題ない。タイトル・概要・カテゴリだけなら 1 件あたり数百バイト程度で、数百本でも数百 KB に収まる。1000 本を超えて転送量や絞り込み速度が気になり始めたら、Pagefind のような静的全文検索ライブラリに切り替えるのが良い。

Q. なぜ method="get" にこだわるんですか?

A. JavaScript が読み込まれる前やエラーで止まったときでも、検索語を一覧ページに渡せるからだ。get ならフォーム送信が ?q=... 付きの URL 遷移になり、一覧ページ側が URL パラメータから初期化する設計と噛み合う。漸進的強化の基本形になっている。

Q. Astro の View Transitions と併用できますか?

A. できるが注意点がある。View Transitions でクライアントサイド遷移すると <script> が再実行されないことがあるため、astro:page-load イベントで初期化処理を呼び直す必要がある。素の MPA 遷移なら今回のコードのままで動く。

Q. カテゴリでの絞り込みと併用したいです

A. applyQuery の判定にカテゴリ条件を && で足せばよい。URL パラメータも ?q=...&category=... のように増やし、URLSearchParams で両方読んで初期化すれば、検索とカテゴリ絞り込みを同じ仕組みで共存させられる。

✅ まとめ

Astro の静的サイトに、外部サービスもサーバーも使わず検索ボックスを後付けする方法を紹介した。要点を整理する。

  • 検索は「トップ=入口」「一覧=実処理」の2ページに分けると設計がすっきりする
  • トップは <form action="/posts/" method="get"> にして JS なしでも検索語を渡せるようにする
  • 一覧は define:vars でビルド時の記事データをクライアントへ渡し、display の切り替えでフィルタする
  • history.replaceState で URL と検索状態を同期し、共有・リロードに耐えるようにする
  • DOM とデータ配列の順序を一致させること、本文全文が必要なら外部ライブラリを検討すること

数十本規模の個人ブログなら、この構成で十分実用的な検索になる。同じ Astro 静的サイトを複数まとめて運用する設計はpnpm monorepo で複数 Astro アプリを一元管理する設計ガイドに、コメントや購読者管理のように動的データを足したくなったときの構成はAstro×D1×Drizzle 副業アプリ0円構成ガイドにまとめているので、合わせて読んでみてください。まずは <form method="get"> を置くところから試してみてください。

コメント