レビュー済み·難易度: 初級·更新: 2026-04-18

React で関連サンプル一覧を表示して回遊導線を作る

slug の配列から関連サンプルのメタデータを取得し、カード形式で一覧表示する回遊導線コンポーネントの実装例。

nextjsui-componentrouting

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router の Server Component と Link コンポーネントの基本を理解していること

概要

サンプル詳細ページ下部に「関連サンプル」セクションを設置し、relatedSlugs の配列から各サンプルのメタデータを取得してカード形式で表示する。Server Component でビルド時にデータを読み込み、ゼロ JS でレンダリングできる回遊導線パターン。

インストール

# 追加インストールは不要

実装

サンプルデータ型

// types/sample.ts
export type SampleMeta = {
  slug: string;
  title: string;
  summary: string;
  categories: string[];
  difficulty: "beginner" | "intermediate" | "advanced";
};

関連サンプルを取得するユーティリティ

// lib/content/related.ts
import { getAllSamples } from "./loader";
import type { SampleMeta } from "@/types/sample";

export async function getRelatedSamples(slugs: string[]): Promise<SampleMeta[]> {
  const all = await getAllSamples();
  return slugs
    .map((slug) => all.find((s) => s.slug === slug))
    .filter((s): s is SampleMeta => s !== undefined);
}

関連サンプルカードコンポーネント

// components/sample/RelatedSampleCard.tsx
import Link from "next/link";
import type { SampleMeta } from "@/types/sample";

const DIFFICULTY_LABELS: Record<string, string> = {
  beginner: "初級",
  intermediate: "中級",
  advanced: "上級",
};

const DIFFICULTY_COLORS: Record<string, string> = {
  beginner: "bg-green-100 text-green-700",
  intermediate: "bg-yellow-100 text-yellow-700",
  advanced: "bg-red-100 text-red-700",
};

type Props = {
  sample: SampleMeta;
};

export default function RelatedSampleCard({ sample }: Props) {
  return (
    <Link
      href={`/samples/${sample.slug}`}
      className="group block rounded-lg border p-4 transition hover:border-blue-400 hover:shadow-sm"
    >
      <div className="mb-2 flex items-start justify-between gap-2">
        <h3 className="text-sm font-medium text-gray-900 group-hover:text-blue-600">
          {sample.title}
        </h3>
        <span
          className={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${DIFFICULTY_COLORS[sample.difficulty]}`}
        >
          {DIFFICULTY_LABELS[sample.difficulty]}
        </span>
      </div>
      <p className="line-clamp-2 text-xs leading-relaxed text-gray-500">{sample.summary}</p>
      <div className="mt-3 flex flex-wrap gap-1">
        {sample.categories.map((cat) => (
          <span key={cat} className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">
            {cat}
          </span>
        ))}
      </div>
    </Link>
  );
}

関連サンプル一覧セクション(Server Component)

// components/sample/RelatedSamples.tsx
import { getRelatedSamples } from "@/lib/content/related";
import RelatedSampleCard from "./RelatedSampleCard";

type Props = {
  slugs: string[];
};

export default async function RelatedSamples({ slugs }: Props) {
  if (slugs.length === 0) return null;

  const samples = await getRelatedSamples(slugs);

  if (samples.length === 0) return null;

  return (
    <section className="mt-12 border-t pt-8">
      <h2 className="mb-4 text-lg font-semibold text-gray-800">関連サンプル</h2>
      <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
        {samples.map((sample) => (
          <li key={sample.slug}>
            <RelatedSampleCard sample={sample} />
          </li>
        ))}
      </ul>
    </section>
  );
}

詳細ページへの組み込み

// app/samples/[slug]/page.tsx
import { getSampleBySlug, getAllSamples } from "@/lib/content/loader";
import RelatedSamples from "@/components/sample/RelatedSamples";
import { notFound } from "next/navigation";

export async function generateStaticParams() {
  const samples = await getAllSamples();
  return samples.map((s) => ({ slug: s.slug }));
}

export default async function SamplePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const sample = await getSampleBySlug(slug);

  if (!sample) notFound();

  return (
    <article className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="mb-4 text-2xl font-bold">{sample.title}</h1>
      <p className="mb-8 text-gray-600">{sample.summary}</p>

      {/* MDX 本文 */}
      <div className="prose">{/* ... */}</div>

      {/* 関連サンプル */}
      <RelatedSamples slugs={sample.relatedSlugs ?? []} />
    </article>
  );
}

ポイント

  • getRelatedSamplesgetAllSamples() の結果から slug でフィルタする。存在しない slug は filter で除外し、relatedSlugs に古い参照が残っていてもエラーにならない
  • RelatedSamples は async Server Component として実装する。generateStaticParams と組み合わせることでビルド時に静的生成され、クライアント JS なしで表示できる
  • カードに line-clamp-2 を使い、長い summary を 2 行で切り詰める。カード高さが揃い、グリッドレイアウトが整然とする
  • group クラスを <Link> に付け、group-hover:text-blue-600 でカード全体ホバー時にタイトルの色を変える。クリッカブル領域が広くユーザビリティが高い
  • 関連サンプルが 0 件のときは null を返してセクション自体を非表示にする。relatedSlugs が空配列や未定義の場合も安全に処理できる

注意点

react-search-highlight はテキストハイライト表示。react-keyword-empty-state は0件時の表示。これは relatedSlugs から関連サンプルのメタデータを取得してカード一覧を表示する回遊導線コンポーネントに特化。詳細ページ下部に設置する用途を想定。

関連サンプル