Next.jsでブログを作る #3 タグと検索
前回は記事一覧と詳細ページを完成させました。今回はヘッダーに先に張っておいた2つのリンク(/tags、/search)を埋めていきます — タグシステムと検索機能が追加されます。
タグ一覧ページ #
まずすべてのタグを表示するページから。各タグの隣にそのタグが付いた記事の数を表示します。
ユーティリティ関数の追加 #
src/app/lib/posts.jsにタグ関連の関数を追加します。
export function getAllTags() {
const posts = getAllPosts();
const tagCount = {};
for (const post of posts) {
for (const tag of post.frontmatter.tags ?? []) {
tagCount[tag] = (tagCount[tag] ?? 0) + 1;
}
}
return Object.entries(tagCount)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
export function getPostsByTag(tag) {
return getAllPosts().filter(post =>
(post.frontmatter.tags ?? []).includes(tag)
);
}getAllTagsは[{ tag, count }, ...]の形の配列を返します (件数の降順)。getPostsByTag(tag)はそのタグが付いた記事だけをフィルタリング。
/tagsページ
#
src/app/tags/page.js。
import Link from 'next/link';
import { getAllTags } from '../lib/posts';
export default function TagsPage() {
const tags = getAllTags();
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>タグ</h1>
{tags.length === 0 ? (
<p>タグがありません。</p>
) : (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{tags.map(({ tag, count }) => (
<Link
key={tag}
href={`/tags/${tag}`}
style={{
padding: '6px 12px',
background: '#f0f0f0',
borderRadius: '16px',
textDecoration: 'none',
color: '#333',
}}
>
#{tag} <span style={{ color: '#888', fontSize: '12px' }}>({count})</span>
</Link>
))}
</div>
)}
</main>
);
}保存して/tagsにアクセスすると、すべてのタグが (記事数の降順で) 表示されます。クリックするとそのタグの記事まとめページに遷移します。
タグ別記事まとめページ #
src/app/tags/[tag]/page.js。
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getAllTags, getPostsByTag } from '../../lib/posts';
import PostCard from '../../PostCard';
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}
export default async function TagPage({ params }) {
const { tag } = await params;
const decodedTag = decodeURIComponent(tag);
const posts = getPostsByTag(decodedTag);
if (posts.length === 0) {
notFound();
}
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<Link href="/tags" style={{ fontSize: '14px' }}>← タグ一覧</Link>
<h1>#{decodedTag}</h1>
<p>{posts.length}件の記事</p>
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);
}主なポイント。
decodeURIComponent
#
URLの:tag部分に日本語のタグ(例: 「お知らせ」)が入ってくると、ブラウザが自動でURLエンコードします (/tags/%E3%81%8A%E7%9F%A5%E3%82%89%E3%81%9B)。params.tagで受け取るときはエンコードされたまま入ってくるので、decodeURIComponentで復元しないとデータとマッチしません。
generateStaticParamsで静的生成
#
export async function generateStaticParams() {
return getAllTags().map(({ tag }) => ({ tag }));
}存在するタグについてビルド時に静的ページを生成します。記事詳細ページと同じパターン。
PostCardの再利用 #
PostCardをそのまま持ってきて使います。#2で記事一覧用に作ったコンポーネントですが、同じ見た目がタグページにも合います。再利用可能な単位として分離しておいた効果がここで出ます。
動作確認 (タグの部分) #
/tags— すべてのタグが件数順で表示- タグをクリック → そのタグの記事まとめページに遷移
- 日本語タグ (
/tags/お知らせのような) URLが正常に動作 - 存在しないタグ (
/tags/non-existent-tag) → 404 - 記事カードのタグをクリックしても同じページに遷移
検索 — searchParams #
検索はタグとは少し違うパターンです。/search?q=Reactのようなクエリストリングで動作するので、動的フォルダではなく同じページ内でsearchParamsを読む方式です。
検索ユーティリティ関数 #
src/app/lib/posts.js。
export function searchPosts(query) {
if (!query || !query.trim()) return [];
const q = query.toLowerCase();
return getAllPosts().filter(post => {
const title = post.frontmatter.title.toLowerCase();
const description = (post.frontmatter.description ?? '').toLowerCase();
const content = post.content.toLowerCase();
return title.includes(q) || description.includes(q) || content.includes(q);
});
}シンプルなsubstringマッチです。実戦では形態素解析、重み付けなどが入りますが、学習目的には十分です。大きなブログならAlgolia、Meilisearch、Pagefindのような検索エンジンを導入するのが一般的です。
検索ページ #
src/app/search/page.js。
import { searchPosts } from '../lib/posts';
import PostCard from '../PostCard';
import SearchInput from './SearchInput';
export default async function SearchPage({ searchParams }) {
const params = await searchParams;
const query = params.q ?? '';
const results = query ? searchPosts(query) : [];
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>検索</h1>
<SearchInput defaultQuery={query} />
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
"{query}" の検索結果 {results.length}件
</p>
)}
{query && results.length === 0 ? (
<p style={{ color: '#888' }}>検索結果がありません。</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
)}
</main>
);
}主なポイント。
searchParamsもPromise
#
const params = await searchParams;
const query = params.q ?? '';paramsと同様、Next.js 15からsearchParamsもPromiseです。awaitしたあとオブジェクトとして使用します。
検索ページは動的レンダリング #
searchParamsを読むページは自動的に動的レンダリングモードになります。リクエストごとに検索が新たに実行されるので自然です (静的生成にすると、すべてのあり得る検索語を事前に知ることはできないので)。
検索入力欄 — Client Component #
検索語の入力欄はユーザーインタラクションが必要なのでClient Componentです。
src/app/search/SearchInput.jsx。
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const router = useRouter();
function handleSubmit(e) {
e.preventDefault();
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
} else {
router.push('/search');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索語を入力後にエンター"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
</form>
);
}useRouterを使ってフォーム送信時に/search?q=...に遷移させます。遷移すると同じページが新しいsearchParamsで再びレンダリングされ、結果が更新されます。
動作確認 (検索の部分) #
/search— 空の検索ページ- 検索語を入力後にエンター → URLが
/search?q=Reactなどに変わって結果が表示 - URLを直接入力 (
/search?q=お知らせ) — 検索がそのまま動作 - 検索結果がない場合は案内文
- 結果記事のタグをクリック → タグページに遷移
URLが検索状態をそのまま含んでいるのでURLを共有すれば同じ検索結果を見られ、再読み込みしても検索語が維持されます。SPAの検索ボックスでは別途処理が必要なことが、自然に解決されます。
デバウンス検索 (オプション) #
今の検索はフォーム送信(エンター)が必要です。入力中にリアルタイム検索したい場合は、useDebounce(#13)を活用したデバウンスパターンを使えます。
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export default function SearchInput({ defaultQuery = '' }) {
const [query, setQuery] = useState(defaultQuery);
const debounced = useDebounce(query, 400);
const router = useRouter();
useEffect(() => {
const target = debounced.trim()
? `/search?q=${encodeURIComponent(debounced.trim())}`
: '/search';
router.replace(target);
}, [debounced, router]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="入力すると自動検索"
style={{ width: '100%', padding: '8px', fontSize: '16px' }}
/>
);
}400ms入力が止まるとURLを更新し、するとServer Componentが再実行されて検索結果を返してくれます。router.replaceを使った理由は、デバウンスごとにhistoryに新しいエントリが積もらないようにするためです (戻るボタンが検索キー入力1つずつ逆方向に進むのを防止)。
このシリーズではシンプルさのためフォーム送信方式で進みますが、デバウンスパターンは知っておくと良いです。
空の状態の処理を精緻化 #
検索ページの空の状態をもっと親切にしてみましょうか? 検索語がないとき、検索したけれど結果がないとき、結果があるときを区別します。
// ...
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<h1>検索</h1>
<SearchInput defaultQuery={query} />
{!query && (
<p style={{ marginTop: '16px', color: '#888' }}>
タイトル、要約、本文から検索します。
</p>
)}
{query && (
<p style={{ marginTop: '16px', color: '#555' }}>
"{query}" の検索結果 {results.length}件
</p>
)}
{query && results.length === 0 && (
<p style={{ color: '#888' }}>
検索結果がありません。別のキーワードを試してみてください。
</p>
)}
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{results.map(post => (
<PostCard key={post.slug} post={post} />
))}
</ul>
</main>
);3つの状態を明示的に分岐 — #7で扱った条件付きレンダリングそのままです。
1つ押さえておくこと — 静的 vs 動的ルート #
今回の記事では2つのルーティングパターンが登場しました。
| ルート | パターン | 静的/動的 | 理由 |
|---|---|---|---|
/tags/[tag] | 動的フォルダ + generateStaticParams | 静的 | 取り得る値がビルド時にわかっている |
/search?q=... | 同じページ + searchParams | 動的 | 検索語が無限通り、事前生成は不可 |
この違いを意識すると、新しいページを設計するときにどのツールを使うかが自然に決まります。わかっている値の集合 → 動的ルート + 静的生成、任意入力 → クエリパラメータ + 動的レンダリング。
おわりに #
今回の記事では2つのルーティングパターンを扱いました。
- タグ:
/tags/[tag]動的フォルダ +generateStaticParamsで静的生成 - 検索:
/search?q=...クエリパラメータ + 動的レンダリング decodeURIComponentで日本語URLを処理- 検索入力欄はClient Component、結果ページはServer Component
- (オプション)
useDebounceでリアルタイム検索
ここまで私たちのブログは読み取り専用でした。記事を見て検索して分類しただけで、ユーザーが何かを入力するアクションはありませんでした。次の記事「Next.jsでブログを作る #4 コメント (Server Actions)」では、各記事にコメントを付ける機能を作りながら、モダンReactシリーズで学んだServer Actionsを本格的に活用してみます。