PR

【Next.jsブログ構築】カテゴリー・タグページを静的エクスポートで実装する方法

Next.jsブログのカテゴリー・タグページ実装を解説するアイキャッチ画像。カテゴリーページとタグページの構成図、ノートPCに表示されたブログ一覧画面、静的エクスポート対応のイメージを柔らかい雰囲気で描いたイラスト VPS・RentalServer
この記事は約24分で読めます。
記事内に広告が含まれています。
スポンサーリンク

これまでの Next.js ブログサイト構築シリーズをまとめたページは以下の通りです。

第13回でヘッダーのナビゲーションに
「Next.js」「Tech」「Notes」というリンクを設置しましたが、
ページそのものはまだ作っていなかったので、実際にクリックすると 404 エラーになっていました。
見た目を先行させて、中身を後回しにした状態です。

今回の第16回では、このリンクの中身を作成します。
フロントマターの categorytags の値をもとに、
記事を自動で絞り込んで一覧表示するカテゴリーページとタグページを実装します。

この記事では以下の内容を解説します。

  • lib/posts.ts にカテゴリー・タグ取得用の関数を追加する方法
  • [slug] フォルダを使ったカテゴリーページ・タグページの作成手順
  • 静的エクスポート環境で記事 0 件のカテゴリーを 404 にしない設計
  • 個別記事ページのカテゴリー・タグをクリックできるリンクに変更する方法

記事の前提条件と対象読者

この記事は、以下の環境と進捗を前提として手順を解説しています。

  • Next.js のバージョン: Next.js 16.2 系( React 19)環境の App Router を使用しています。
  • ビルド方式: output: 'export' による静的エクスポートモードで動作しています。
  • 対象読者: シリーズ第15回までを完了し、 GA4 とサーチコンソールの設定が完了している方を対象としています。

今回作成するページの構造

今回の実装で、以下の URL にページが生成されます。

/category/nextjs   → カテゴリーが「nextjs」の記事一覧
/category/tech     → カテゴリーが「tech」の記事一覧
/category/notes    → カテゴリーが「notes」の記事一覧
/tags/obsidian     → タグが「obsidian」の記事一覧
/tags/ubuntu       → タグが「ubuntu」の記事一覧
(以下、フロントマターに存在するタグの数だけ自動生成される)

この仕組みは第7回(個別記事ページ)で実装した動的ルーティングの応用です。
フォルダ名を [slug] のようにカッコで囲むと「ここにどんな文字列が来ても受け付ける」という型枠になります。
/category/nextjs にアクセスすると slug = 'nextjs' としてページが呼び出される仕組みです。

今回変更・作成するファイル

ファイル操作
example-blog/lib/posts.ts末尾に4つの関数を追記
example-blog/app/category/[slug]/page.tsx新規作成
example-blog/app/tags/[slug]/page.tsx新規作成
example-blog/app/posts/[id]/page.tsxカテゴリー・タグ部分を修正

STEP 1:lib/posts.ts に4つの関数を追加する

lib/posts.ts にはすでに categorytags を Markdown のフロントマターから取得する処理が入っています。今回はここにさらに4つの関数を追加します。

Cursor やテキストエディタなどで example-blog/lib/posts.ts を開き、ファイルの末尾(最終行の下)に以下を追記して保存してください。

// ============================
// カテゴリー・タグ関連の関数
// ============================

// 全記事からカテゴリー一覧(重複なし)を取得する
export function getAllCategories(): string[] {
  const allPosts = getSortedPostsData();
  const categories = allPosts
    .map((post) => post.category)
    .filter((c): c is string => !!c);
  return Array.from(new Set(categories));
}

// 全記事からタグ一覧(重複なし)を取得する
export function getAllTags(): string[] {
  const allPosts = getSortedPostsData();
  const tags = allPosts.flatMap((post) => post.tags);
  return Array.from(new Set(tags));
}

// 指定カテゴリーの記事一覧を返す
export function getPostsByCategory(category: string): PostData[] {
  return getSortedPostsData().filter((post) => post.category === category);
}

// 指定タグを持つ記事一覧を返す
export function getPostsByTag(tag: string): PostData[] {
  return getSortedPostsData().filter((post) => post.tags.includes(tag));
}

各関数の役割と仕組み

getAllCategories() ── カテゴリー一覧を取得する

全記事の category フィールドを集め、重複を取り除いた配列として返します。

たとえば記事が5件あり、カテゴリーが nextjstechnextjsnotesnextjs と設定されている場合、['nextjs', 'tech', 'notes'] を返します。

.filter((c): c is string => !!c) という書き方は、category が空文字や未設定(undefined)の記事を除外するための TypeScript の書き方です。
(c): c is string は「この処理を通った値は必ず string 型だ」と TypeScript に伝えています。

Array.from(new Set(...)) は重複を取り除くための定番パターンです。
Set は同じ値を2つ以上持てないデータ構造なので、配列を Set に変換するだけで重複が消えます。Array.from() でそれを普通の配列に戻しています。

getAllTags() ── タグ一覧を取得する

全記事のタグを集めて、重複なしの一覧を返します。

各記事の tags はすでに配列です(例:['ubuntu', 'vps'])。
これを普通の map() で取り出すと「配列の中に配列が入った」状態になってしまいます。
flatMap() は変換後に1段階の入れ子を自動的に平坦化してくれるため、一次元の配列としてまとめられます。

// map() を使った場合(配列の配列になってしまう)
[['ubuntu', 'vps'], ['markdown'], ['obsidian', 'ubuntu']]

// flatMap() を使った場合(一次元の配列になる)
['ubuntu', 'vps', 'markdown', 'obsidian', 'ubuntu']

getPostsByCategory(category) ── カテゴリーで記事を絞り込む

引数で指定されたカテゴリー名と一致する記事だけを返します。
カテゴリーページからこの関数を呼ぶことで、表示する記事の一覧を取得します。

getPostsByTag(tag) ── タグで記事を絞り込む

引数で指定されたタグを持つ記事だけを返します。
tags.includes(tag) で、タグの配列の中に一致するものがあるかを判定しています。

STEP 2:カテゴリーページを作成する(静的エクスポート対応)

フォルダ構造を作る

Cursor の左側ファイル一覧で、以下の構造になるようにフォルダとファイルを作成してください。
mkdir コマンドを使ってディレクトリを作成して、nano などのテキストエディタを使用する方法もあります。

example-blog/
└── app/
    └── category/
        └── [slug]/
            └── page.tsx  ← 今回新しく作成するファイル

注意:[slug] のカッコは半角角カッコです

フォルダ名の [slug] は全角カッコ(【】)ではなく、半角角カッコ([])を使います。
ターミナルで作成する場合はシェルに特殊文字として誤認識されないよう、バックスラッシュでエスケープする必要があります。

mkdir -p ~/example-blog/app/category/\[slug\]

page.tsx に以下のコードを貼り付ける

import Link from 'next/link';
import Image from 'next/image';
import { getAllCategories, getPostsByCategory } from '../../../lib/posts';

// カテゴリースラッグ(例:nextjs)を表示名(例:Next.js)に変換する
function formatCategoryName(slug: string): string {
  const map: Record<string, string> = {
    nextjs: 'Next.js',
    tech: 'Tech',
    notes: 'Notes',
  };
  // ▼ ご自身のカテゴリー名に合わせて変更してください
  return map[slug] ?? slug;
}

// 静的エクスポート用:ビルド時にカテゴリーページの URL を事前生成する
export async function generateStaticParams() {
  const fromPosts = getAllCategories();
  // ▼ ヘッダーに登録済みのカテゴリーは記事 0 件でもページを生成する
  // ▼ ご自身のカテゴリー名に合わせて変更してください
  const fixed = ['nextjs', 'tech', 'notes'];
  const all = Array.from(new Set([...fixed, ...fromPosts]));
  return all.map((slug) => ({ slug }));
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const name = formatCategoryName(slug);
  return {
    title: `${name} の記事一覧`,
    description: `${name} カテゴリーの記事一覧です。`,
  };
}

export default async function CategoryPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const posts = getPostsByCategory(slug);
  const name = formatCategoryName(slug);

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">

      {/* ページタイトル */}
      <h1 className="text-xl font-bold text-[#9ca3af] mb-6 pb-2 border-b-2 border-[#0f6e56]">
        カテゴリー:{name}
        <span className="ml-3 text-sm font-normal text-[#6a7080]">
          {posts.length} 件
        </span>
      </h1>

      {posts.length === 0 ? (
        <p className="text-[#6a7080]">このカテゴリーにはまだ記事がありません。</p>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
          {posts.map((post) => (
            <article
              key={post.id}
              className="bg-[#2a2d38] rounded-lg border border-[#3a3f4a] overflow-hidden hover:border-[#4a5060] transition-colors"
            >
              <Link href={`/posts/${post.id}`} className="block">

                {/* アイキャッチ画像エリア */}
                <div className="relative w-full aspect-[1200/630] bg-[#1e2028]">
                  {post.ogImage && post.ogImage !== '' ? (
                    <Image
                      src={post.ogImage}
                      alt={post.title}
                      fill
                      className="object-cover"
                    />
                  ) : (
                    <div className="absolute inset-0 flex items-center justify-center text-[#3a3f4a]">
                      <svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
                      </svg>
                    </div>
                  )}
                </div>

                {/* 記事情報 */}
                <div className="p-4">
                  <p className="text-xs text-[#6a7080] mb-2">{post.date}</p>
                  <h2 className="text-sm font-bold text-[#e2e4ea] leading-snug line-clamp-3">
                    {post.title}
                  </h2>
                </div>

              </Link>
            </article>
          ))}
        </div>
      )}

      <div className="text-center mt-4">
        <Link href="/" className="text-sm text-[#4dd4b0] hover:text-[#80d0bc] transition-colors">
          ← 記事一覧に戻る
        </Link>
      </div>

    </div>
  );
}

コードの解説

Record<string, string> とは

formatCategoryName 関数内にある Record<string, string> は TypeScript の型定義です。
「キー(左側)が string 型、値(右側)も string 型のオブジェクト」であることを TypeScript に伝えています。

フロントマターのカテゴリー値は URL として使うため nextjs のように英小文字のみで構成しています。
これをそのままページタイトルに使うと「カテゴリー:nextjs」という表示になるため、この関数で nextjs → Next.js のように読みやすい表示名に変換しています。
?? slug の部分は「map に登録されていないカテゴリーはそのまま表示する」というフォールバック処理です。

静的エクスポートで記事 0 件のカテゴリーを 404 にしない設計

静的エクスポート(output: 'export')では、動的ルーティングのページをビルド時に生成するため、どの URL を作るかを generateStaticParams 関数で事前に返す必要があります。

通常は「記事に存在するカテゴリーだけを返す」設計で十分です。
しかし今回はヘッダーナビゲーションに3つのカテゴリーリンクを設置しているため、記事が 0 件のカテゴリーでもページが必要でした。
記事がないカテゴリーは getAllCategories() の結果に含まれず、その URL のページが生成されないため、クリックすると 404 になってしまいます。

そこで、ヘッダーに登録した3カテゴリーを fixed として固定し、記事から取得したカテゴリー(fromPosts)と合わせて Set でマージする方法を採りました。

const fixed = ['nextjs', 'tech', 'notes'];   // 常に生成するカテゴリー
const fromPosts = getAllCategories();          // 記事から取得したカテゴリー
const all = Array.from(new Set([...fixed, ...fromPosts]));

[...fixed, ...fromPosts]... はスプレッド構文です。
配列の「中身」を展開して並べる記法で、2つの配列を1つにまとめるときに使います。
それを Set に渡すことで重複が取り除かれ、すべてのカテゴリーが重複なしにまとまります。

この設計により、将来新しいカテゴリーを記事に使い始めれば自動的にページが追加されます。
既存の3カテゴリーは記事が 0 件でも「このカテゴリーにはまだ記事がありません」と表示されるページが維持されます。

Next.jsで構築しているブログサイトでカテゴリーページが完成した画像

Next.jsで構築している、「LIFEWORK Blog Next」の「Next.js」カテゴリーページの画像です。
ページ上部にカテゴリー名や、カテゴリー内のページ数が表示され、見やすくなっていると思います。

STEP 3:タグページを作成する

タグページも同じ構造で作成します。
フォルダ名は /tags/[slug]/ です(/tag/ ではなく /tags/ と s が付く点に注意してください)。

Cursor で example-blog/app/tags/[slug]/page.tsx を新規作成し、以下のコードを貼り付けてください。

import Link from 'next/link';
import Image from 'next/image';
import { getAllTags, getPostsByTag } from '../../../lib/posts';

export async function generateStaticParams() {
  const tags = getAllTags();
  return tags.map((slug) => ({ slug }));
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return {
    title: `#${slug} の記事一覧`,
    description: `タグ「${slug}」の記事一覧です。`,
  };
}

export default async function TagPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const posts = getPostsByTag(slug);

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">

      {/* ページタイトル */}
      <h1 className="text-xl font-bold text-[#9ca3af] mb-6 pb-2 border-b-2 border-[#0f6e56]">
        タグ:<span className="text-[#4dd4b0]">#{slug}</span>
        <span className="ml-3 text-sm font-normal text-[#6a7080]">
          {posts.length} 件
        </span>
      </h1>

      {posts.length === 0 ? (
        <p className="text-[#6a7080]">このタグの記事はまだありません。</p>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
          {posts.map((post) => (
            <article
              key={post.id}
              className="bg-[#2a2d38] rounded-lg border border-[#3a3f4a] overflow-hidden hover:border-[#4a5060] transition-colors"
            >
              <Link href={`/posts/${post.id}`} className="block">

                <div className="relative w-full aspect-[1200/630] bg-[#1e2028]">
                  {post.ogImage && post.ogImage !== '' ? (
                    <Image
                      src={post.ogImage}
                      alt={post.title}
                      fill
                      className="object-cover"
                    />
                  ) : (
                    <div className="absolute inset-0 flex items-center justify-center text-[#3a3f4a]">
                      <svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
                      </svg>
                    </div>
                  )}
                </div>

                <div className="p-4">
                  <p className="text-xs text-[#6a7080] mb-2">{post.date}</p>
                  <h2 className="text-sm font-bold text-[#e2e4ea] leading-snug line-clamp-3">
                    {post.title}
                  </h2>
                </div>

              </Link>
            </article>
          ))}
        </div>
      )}

      <div className="text-center mt-4">
        <Link href="/" className="text-sm text-[#4dd4b0] hover:text-[#80d0bc] transition-colors">
          ← 記事一覧に戻る
        </Link>
      </div>

    </div>
  );
}

カテゴリーページとの主な違いは以下の2点です。

  • formatCategoryName のような変換関数がない。タグ名は URL と表示名が同じためそのまま表示します。
  • generateStaticParams はフロントマターに実際に存在するタグだけを生成します。ヘッダーにタグリンクは設置していないため、固定リストは不要です。
Next.jsで構築しているブログサイトでタグページが完成した画像

タグページを表示したところです。
ページ上部に、タグ名が表示されて、タグが付いた記事の件数も表示されています。

STEP 4:個別記事ページのカテゴリー・タグをリンクに変更する

example-blog/app/posts/[id]/page.tsx を開き、カテゴリーバッジとタグの表示部分を修正します。

Link コンポーネントはすでにファイルの先頭でインポートされているため、インポートの追加は不要です。

カテゴリーバッジを <span> から <Link> に変更する

変更前:

{postData.category && (
  <span className="inline-block bg-[#0f6e56] text-white text-xs px-3 py-1 rounded mb-3">
    {postData.category}
  </span>
)}

変更後:

{postData.category && (
  <Link
    href={`/category/${postData.category}`}
    className="inline-block bg-[#0f6e56] text-white text-xs px-3 py-1 rounded mb-3 hover:bg-[#0a5242] transition-colors"
  >
    {postData.category}
  </Link>
)}

<span> はただ文字を表示するだけの要素です。
<Link> に変えることでクリックできるようになり、対応するカテゴリーページへ遷移します。
hover:bg-[#0a5242] はマウスを乗せたときに色が少し暗くなるホバーエフェクトです。

タグを <span> から <Link> に変更する

変更前:

{tags.map((tag: string) => (
  <span
    key={tag}
    className="text-xs bg-[#3a3f4a] text-[#9ca3af] px-2 py-0.5 rounded border border-[#4a5060]"
  >
    {tag}
  </span>
))}

変更後:

{tags.map((tag: string) => (
  <Link
    key={tag}
    href={`/tags/${tag}`}
    className="text-xs bg-[#3a3f4a] text-[#9ca3af] px-2 py-0.5 rounded border border-[#4a5060] hover:bg-[#0f6e56] hover:text-white hover:border-[#0f6e56] transition-colors"
  >
    #{tag}
  </Link>
))}

タグの先頭に # を付けることで、見た目にもタグであることが伝わりやすくなります。
ホバー時はティール色に変化し、カテゴリーバッジと統一感のあるデザインになります。

STEP 5:ビルドとデプロイを実行する

cd ~/example-blog
./deploy.sh

deploy.sh はビルドと VPS へのファイル転送を一括で実行するスクリプトです。
コマンド1つで本番環境への反映が完了します。

動作確認

デプロイ完了後、以下の項目を順番に確認してください。

  1. ヘッダーナビの「Next.js」「Tech」「Notes」をそれぞれクリックし、カテゴリーページが開くこと
  2. 記事がないカテゴリーでは「このカテゴリーにはまだ記事がありません」と表示されること
  3. 個別記事ページのカテゴリーバッジをクリックして、対応するカテゴリーページへ遷移すること
  4. 個別記事ページのタグをクリックして、対応するタグページへ遷移すること

まとめ

第13回からずっと 404 だったヘッダーリンクが、今回でやっと本物のページになりました。

実装してみると、コードの量そのものは多くありません。
lib/posts.ts に4つの関数を追加して、それを呼び出すページを2つ作るだけです。
動的ルーティングの仕組みは第7回の個別記事ページで一度経験しているため、同じパターンの応用でスムーズに進められました。

今回の実装で個人的に面白かったのは、generateStaticParams の設計を考えるところでした。
「記事があるカテゴリーだけページを作ればいい」と最初は思っていましたが、ヘッダーリンクとの整合性を考えると、記事 0 件でもページが必要だと気づきました。
固定リストと動的リストを Set でマージするという小さな工夫ひとつで、「ナビのリンクをクリックしたら 404」という状況が完全に解消できました。

ヘッダーリンクやタグから記事が表示されるようになると、ちゃんとしたサイトになってきたことを強く実感します。
今後も、Next.jsによるブログサイトの構築を続けていきますので、次の回もぜひご一読ください。

今回追加した関数(lib/posts.ts)

関数名役割
getAllCategories()カテゴリー一覧を重複なしで取得する
getAllTags()タグ一覧を重複なしで取得する
getPostsByCategory(category)指定カテゴリーの記事を返す
getPostsByTag(tag)指定タグを持つ記事を返す

今回作成したページ

ページURL パターン備考
カテゴリーページ/category/[slug]記事 0 件でもページを生成
タグページ/tags/[slug]記事に存在するタグだけ生成

次回は個別記事ページの下部に、同じカテゴリーやタグの関連記事を表示する機能を実装します。

コメント

タイトルとURLをコピーしました