概要
サンプル詳細ページ下部に「関連サンプル」セクションを設置し、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>
);
}
ポイント
getRelatedSamplesはgetAllSamples()の結果から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が空配列や未定義の場合も安全に処理できる