Next.jsでブログを作る #3 タグと検索

読了 8分

前回は記事一覧と詳細ページを完成させました。今回はヘッダーに先に張っておいた2つのリンク(/tags/search)を埋めていきます — タグシステム検索機能が追加されます。

タグ一覧ページ #

まずすべてのタグを表示するページから。各タグの隣にそのタグが付いた記事の数を表示します。

ユーティリティ関数の追加 #

src/app/lib/posts.jsにタグ関連の関数を追加します。

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

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

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で記事一覧用に作ったコンポーネントですが、同じ見た目がタグページにも合います。再利用可能な単位として分離しておいた効果がここで出ます。

動作確認 (タグの部分) #

  1. /tags — すべてのタグが件数順で表示
  2. タグをクリック → そのタグの記事まとめページに遷移
  3. 日本語タグ (/tags/お知らせのような) URLが正常に動作
  4. 存在しないタグ (/tags/non-existent-tag) → 404
  5. 記事カードのタグをクリックしても同じページに遷移

検索 — searchParams #

検索はタグとは少し違うパターンです。/search?q=Reactのようなクエリストリングで動作するので、動的フォルダではなく同じページ内でsearchParamsを読む方式です。

検索ユーティリティ関数 #

src/app/lib/posts.js

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マッチです。実戦では形態素解析、重み付けなどが入りますが、学習目的には十分です。大きなブログならAlgoliaMeilisearchPagefindのような検索エンジンを導入するのが一般的です。

検索ページ #

src/app/search/page.js

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

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で再びレンダリングされ、結果が更新されます。

動作確認 (検索の部分) #

  1. /search — 空の検索ページ
  2. 検索語を入力後にエンター → URLが/search?q=Reactなどに変わって結果が表示
  3. URLを直接入力 (/search?q=お知らせ) — 検索がそのまま動作
  4. 検索結果がない場合は案内文
  5. 結果記事のタグをクリック → タグページに遷移

URLが検索状態をそのまま含んでいるのでURLを共有すれば同じ検索結果を見られ、再読み込みしても検索語が維持されます。SPAの検索ボックスでは別途処理が必要なことが、自然に解決されます。

デバウンス検索 (オプション) #

今の検索はフォーム送信(エンター)が必要です。入力中にリアルタイム検索したい場合は、useDebounce(#13)を活用したデバウンスパターンを使えます。

src/app/search/SearchInput.jsx (デバウンス版)
'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つずつ逆方向に進むのを防止)。

このシリーズではシンプルさのためフォーム送信方式で進みますが、デバウンスパターンは知っておくと良いです。

空の状態の処理を精緻化 #

検索ページの空の状態をもっと親切にしてみましょうか? 検索語がないとき、検索したけれど結果がないとき、結果があるときを区別します。

src/app/search/page.js (修正)
// ...

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を本格的に活用してみます。

X