Next.js での検索・絞り込み機能

保育園グループサイト(全70園規模)で実装した「条件による検索・絞り込み」の仕組みを、自分の言葉で理解しなおすためのまとめ。ページネーションとは考え方が根本的に違う点がポイント。

ページネーションとの決定的な違い

前回まとめたページネーションは「1ページ目」「2ページ目」というように、それぞれが独立した別のHTMLファイル(別のURL)だった。静的サイトはビルド時に全パターンのファイルを事前に作っておく必要があったから。

検索・絞り込みはこれとは全く違うアプローチになる。「エリアで絞り込む」「年齢で絞り込む」「キーワードで絞り込む」といった条件の組み合わせは無数にあり、その組み合わせごとに静的ファイルを事前生成するのは現実的ではない。そこで絞り込み機能は、ページを移動せず、今開いているページの中でJavaScriptだけを使ってその場で表示を切り替える方式で作る。

仕組みの核心:データは全部すでにブラウザの中にある

ここが理解の一番のポイントだった。保育園の一覧ページを開いた時点で、全70園分のデータはビルド時にmicroCMSから取得済みで、そのページのHTMLの中(正確にはNext.jsが埋め込む初期データ)にすでに全部含まれている。

つまり「絞り込みボタンを押した瞬間にサーバーへ問い合わせて該当する園だけを取得しなおす」のではなく、「手元にすでにある70件の配列を、条件に合うものだけ残るようにJavaScriptでフィルタリングする」だけで済む。サーバーとの通信が一切発生しないので、体感速度が一瞬になる。これが静的サイトで絞り込み機能を作るときの基本的な考え方。

実装パターン:親でデータ取得、子で絞り込み

Next.jsのApp Routerでは、データ取得はサーバーコンポーネント(一覧ページ本体)が担当し、実際にユーザーが操作する絞り込みUIはクライアントコンポーネントとして切り出す、という役割分担にする。

一覧ページ本体は今まで通りmicroCMSから全件を取得し、それをそのまま絞り込み用の子コンポーネントにpropsとして渡す。子コンポーネントの先頭には'use client'という宣言が必須になる。これは、ユーザーの入力を受け取って状態を書き換える処理(useState)や、クリック・入力といったブラウザ側のイベント処理はサーバーコンポーネントではできず、クライアント側で動くコンポーネントとして明示する必要があるため。

状態管理とフィルタリングの中身

子コンポーネントの中では、選んでいる条件(検索キーワードや、チェックした絞り込み条件)をuseStateで持っておく。表示する一覧は、受け取った全件データに対して配列のfilterメソッドを使い、現在の条件に一致するものだけを残した新しい配列として毎回計算しなおす。

条件が複数ある場合(保育園サイトなら、エリアと年齢とキーワードを同時に絞り込むようなケース)は、filterの中の条件式をand条件でつなげればよい。キーワードが入力されていなければその条件は素通りさせ、エリアが選ばれていればそのエリアと一致するものだけ残す、というように、条件ごとに「その条件が指定されていなければ常に真」というガードを入れておくと、複数条件を自然に組み合わせられる。

件数が多くなってくると、画面が再描画されるたびに毎回filterを計算しなおすのは無駄になることがある。その場合はuseMemoというフックで、条件が実際に変わったときだけ計算しなおすようにキャッシュしておくと効率がよい。

この方式が向いている規模・向いていない規模

保育園サイトの70件のように、数十件から数百件程度で、かつ1件あたりのデータ量がそれほど大きくない場合は、全件を先に配ってしまってクライアント側で絞り込む方式がシンプルで速い。サーバーとの通信がないぶん、体感がとても軽くなるのが最大のメリット。

一方で、件数が非常に多い、あるいは1件あたりの情報量(本文の長さなど)が大きい場合は、全件を最初にブラウザへ送ること自体が重くなってしまう。このポートフォリオサイトのブログ記事は220件あるが、絞り込み用の一覧に必要なのはタイトル・種別・日付程度で、本文までは不要なので、絞り込み用に取得するフィールドを絞ってデータ量を抑えれば、同じ「全件取得してクライアント側でfilter」の方式でも問題なく使えると考えている。

件数がさらに桁違いに多くなる場合は、この「全部渡してブラウザ内で絞り込む」方式ではなく、条件が変わるたびにAPIへ問い合わせて必要な分だけ取得しなおす方式に切り替える必要がある。今回のプロジェクトの規模では、そこまでの対応は不要という判断。

URLと連動させる場合の補足