概要
検索結果が 0 件のとき、入力キーワードで全データのタイトル・タグを部分一致検索して関連候補を抽出し、ワンクリックで再検索できるサジェスト UI を表示する。Server Component でデータを渡し、候補抽出ロジックを純粋関数として分離する。
インストール
# 追加インストールは不要
実装
候補抽出の純粋関数
// lib/suggestions.ts
export type SuggestionItem = {
label: string;
query: string;
};
/**
* query のトークンを各サンプルのタイトル・タグと部分一致させて候補を抽出する。
* 最大 maxCount 件を返す。
*/
export function buildSuggestions(
samples: { title: string; tags: string[] }[],
query: string,
maxCount = 5
): SuggestionItem[] {
if (!query.trim()) return [];
const q = query.toLowerCase();
const seen = new Set<string>();
const results: SuggestionItem[] = [];
for (const sample of samples) {
// タグに一致するものをまず集める
for (const tag of sample.tags) {
if (tag.includes(q) && !seen.has(tag)) {
seen.add(tag);
results.push({ label: tag, query: tag });
if (results.length >= maxCount) return results;
}
}
}
// タグで足りなければタイトルの先頭単語を追加
for (const sample of samples) {
const word = sample.title.split(/[\s・]/)[0];
if (word.toLowerCase().includes(q) && !seen.has(word)) {
seen.add(word);
results.push({ label: word, query: word });
if (results.length >= maxCount) return results;
}
}
return results;
}
サジェストコンポーネント
// components/NoResultsSuggestion.tsx
import Link from "next/link";
type Props = {
query: string;
suggestions: { label: string; query: string }[];
resetHref?: string;
};
export function NoResultsSuggestion({ query, suggestions, resetHref = "/samples" }: Props) {
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<p className="text-lg font-semibold text-gray-700">
「{query}」に一致するサンプルは見つかりませんでした
</p>
{suggestions.length > 0 && (
<div>
<p className="mb-2 text-sm text-gray-500">こちらのキーワードはいかがですか?</p>
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((s) => (
<Link
key={s.query}
href={`/samples?q=${encodeURIComponent(s.query)}`}
className="rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-sm text-blue-700 hover:bg-blue-100"
>
{s.label}
</Link>
))}
</div>
</div>
)}
<Link href={resetHref} className="text-sm text-gray-400 hover:underline">
検索条件をリセット
</Link>
</div>
);
}
一覧ページで 0 件時に使う
// app/samples/page.tsx(抜粋)
import { buildSuggestions } from "@/lib/suggestions";
import { NoResultsSuggestion } from "@/components/NoResultsSuggestion";
export default async function SamplesPage({ searchParams }: Props) {
const params = await searchParams;
const q = typeof params.q === "string" ? params.q : "";
const allSamples = await getAllSamples();
const filtered = filterSamples(allSamples, { q });
if (filtered.length === 0 && q) {
const suggestions = buildSuggestions(allSamples, q);
return (
<NoResultsSuggestion
query={q}
suggestions={suggestions}
resetHref="/samples"
/>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((sample) => (
<SampleCard key={sample.slug} sample={sample} query={q} />
))}
</div>
);
}
ポイント
buildSuggestionsは pure function として分離する。allSamplesとqueryだけを受け取り、DOM やルーターに依存しないのでユニットテストが容易- タグからの候補を優先し、足りなければタイトルの先頭単語を追加する 2 段階抽出にすることで、意味的に近い候補が上位に来やすい
seenセットで重複候補を排除する。同じキーワードが複数サンプルに含まれていても 1 回だけ提案する- 候補チップは
<Link href="/samples?q=...">の<a>タグとして実装する。Client Component 不要でサーバーレンダリングでき、ページ遷移も通常のリンクで実現できる qが空のときは候補を返さずEmptyStateに委譲する。サジェストは「キーワード入力 + 0 件」に限定することで表示条件を明確にするmaxCountのデフォルト 5 は画面幅に収まる上限の目安。多すぎるとユーザーが選択に迷うため絞り込む