Next.jsでショップを作る #2 商品カタログ

読了 8分

前回はルートツリーとデータモデルを整え、商品シードJSONまで準備しました。今回は本物の画面を描きます — /products一覧/products/[slug]詳細、2つのページを完成させるのが目標です。どちらのページもServer Componentで作れる領域です。データは静的JSONで、ユーザーインタラクションのない画面だからです。

共通ユーティリティ — 価格フォーマット #

価格はいろいろな場所に登場するので、フォーマット関数を先に分離しておきます。Intl.NumberFormatが標準ライブラリに入っているので、外部依存なしで処理できます。

src/app/lib/format.js

src/app/lib/format.js
const krw = new Intl.NumberFormat('ko-KR', {
  style: 'currency',
  currency: 'KRW',
  maximumFractionDigits: 0,
});

export function formatPrice(amount) {
  return krw.format(amount);
}

24000を入れると₩24,000が出てきます。画面ごとにtoLocaleStringをばらばらに呼ぶ代わりに1か所にまとめておけば、通貨単位が変わるとき (例: 別の通貨への対応) に1行だけ修正すれば済みます。

ProductCardコンポーネント #

一覧とホームの両方で使うカードコンポーネントを先に作ります。カード1枚に入る要素は次のとおりです。

  • 商品画像
  • 商品名
  • 価格
  • 品切れバッジ (stock === 0 のとき)

src/app/components/ProductCard.js

src/app/components/ProductCard.js
import Link from 'next/link';
import Image from 'next/image';
import { formatPrice } from '../lib/format';

export default function ProductCard({ product }) {
  const isOutOfStock = product.stock === 0;

  return (
    <Link href={`/products/${product.slug}`} className="block group">
      <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
        <Image
          src={product.image}
          alt={product.name}
          fill
          sizes="(max-width: 768px) 50vw, 25vw"
          className="object-cover transition group-hover:scale-105"
        />
        {isOutOfStock && (
          <span className="absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs text-white">
            品切れ
          </span>
        )}
      </div>
      <div className="mt-2 text-sm">{product.name}</div>
      <div className="mt-1 text-base font-semibold">{formatPrice(product.price)}</div>
    </Link>
  );
}

next/imagefillモードを使いました。親コンテナがrelative + aspect-squareで決まったサイズを持つと、画像がその中を埋めながらobject-coverで切り取られます。カードの高さがばらばらにならない、いちばんシンプルなパターンです。

Linkがカード全体を包むようにしておくと、クリック領域がカード全体になります。モバイルで特に重要なディテールです。

next/imageのドメイン設定 #

今回の記事の画像はすべてpublic/配下のローカルアセットなので追加設定は要りませんが、後で外部画像 (CDN、S3など) を使うようになるとnext.config.mjsimages.remotePatternsへの登録が必要です。ひとまずデフォルト設定のまま進めます。

画像がまだ用意されていないスラグがあると、next/imageで404が発生して赤いXが表示されることがあります。学習段階では適当なplaceholder画像 (例: https://placehold.co/600x600) を登録するか、JSONのimageパスを/next.svgのように既存アセットへ一時的に変えておけばOKです。外部のplaceholderを使うにはnext.config.mjsにドメインを登録する必要があります。

next.config.mjs — 外部placeholderを使う場合
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'placehold.co' },
    ],
  },
};

export default nextConfig;

/products — 商品一覧ページ #

いよいよ本命のページを作ります。基本は全商品を描き、?category=apparelのようなクエリストリングが来たらそのカテゴリだけを表示します。

src/app/products/page.js

src/app/products/page.js
import Link from 'next/link';
import ProductCard from '../components/ProductCard';
import { getAllCategories, getProductsByCategory } from '../lib/products';

export const metadata = {
  title: '商品',
  description: '販売中の商品一覧です。',
};

export default async function ProductsPage({ searchParams }) {
  const { category } = await searchParams;
  const categories = getAllCategories();
  const products = getProductsByCategory(category);

  return (
    <main className="mx-auto max-w-5xl px-4 py-8">
      <h1 className="text-2xl font-bold">商品</h1>

      <nav className="mt-4 flex flex-wrap gap-2">
        <CategoryChip current={category} value={undefined} label="すべて" />
        {categories.map((c) => (
          <CategoryChip key={c} current={category} value={c} label={c} />
        ))}
      </nav>

      {products.length === 0 ? (
        <p className="mt-8 text-gray-500">条件に合う商品がありません</p>
      ) : (
        <ul className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
          {products.map((p) => (
            <li key={p.slug}>
              <ProductCard product={p} />
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

function CategoryChip({ current, value, label }) {
  const isActive = current === value || (!current && !value);
  const href = value ? `/products?category=${value}` : '/products';

  return (
    <Link
      href={href}
      className={
        'rounded-full border px-3 py-1 text-sm ' +
        (isActive ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:border-gray-500')
      }
    >
      {label}
    </Link>
  );
}

いくつか押さえておきたいポイントがあります。

  • searchParamsはawaitします — Next.js 16からparams / searchParamsはPromiseです。これまでの記事と同じパターンです。
  • カテゴリチップがそのままリンクです — JSがなくても動きます。Server Componentの強みが自然に生きてきます。
  • isActiveの判定current === valueで比較しつつ、「すべて」チップはcurrentがないときにアクティブになるよう別途分岐します。
  • グリッドのレスポンシブ — モバイル2列、タブレット3列、デスクトップ4列です。Tailwindのgrid-cols-{n}できれいに処理できます。

/products/[slug] — 商品詳細ページ #

商品1つを詳しく見せるページです。大きな画像 + 説明 + 価格 + カートボタンが入ります。

src/app/products/[slug]/page.js

src/app/products/[slug]/page.js
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getAllProducts, getProductBySlug } from '../../lib/products';
import { formatPrice } from '../../lib/format';

export function generateStaticParams() {
  return getAllProducts().map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const product = getProductBySlug(slug);
  if (!product) return {};
  return {
    title: product.name,
    description: product.description,
  };
}

export default async function ProductDetailPage({ params }) {
  const { slug } = await params;
  const product = getProductBySlug(slug);

  if (!product) notFound();

  const isOutOfStock = product.stock === 0;

  return (
    <main className="mx-auto max-w-4xl px-4 py-8">
      <div className="grid gap-8 md:grid-cols-2">
        <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
          <Image
            src={product.image}
            alt={product.name}
            fill
            sizes="(max-width: 768px) 100vw, 50vw"
            className="object-cover"
            priority
          />
        </div>

        <div className="flex flex-col gap-4">
          <div className="text-sm text-gray-500">{product.category}</div>
          <h1 className="text-2xl font-bold">{product.name}</h1>
          <div className="text-2xl font-semibold">{formatPrice(product.price)}</div>
          <p className="text-gray-700">{product.description}</p>

          {isOutOfStock ? (
            <button
              type="button"
              disabled
              className="rounded-md bg-gray-300 px-4 py-3 text-white"
            >
              品切れ
            </button>
          ) : (
            <button
              type="button"
              className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
            >
              カートに入れる
            </button>
          )}

          <div className="text-sm text-gray-500">
            在庫: {product.stock}
          </div>
        </div>
      </div>
    </main>
  );
}

主なポイントです。

  • generateStaticParams — ビルド時にすべてのスラグを静的に生成します。JSONのすべての商品がビルド成果物に静的ページとして入ります。ページを開くときのサーバーレンダリングコストがなくなります。
  • generateMetadata — 各商品ページが自分だけの<title><meta description>を持ちます。SEOとSNSプレビューにそのまま反映されます。
  • notFound() — 間違ったスラグが来たら404ページに送ります。if (!product) return <NotFoundUI />のように直接処理するよりすっきりしていて、Next.jsが適切なステータスコードまで一緒に送ってくれます。
  • priority — 詳細ページのメイン画像に優先読み込みのヒントを与えます。LCP (Largest Contentful Paint) が改善されます。
  • 「カートに入れる」ボタン — 今は空のボタンです。次の記事でクリックハンドラを付けます。

ホームページの整理 #

ホームはカタログへの入口の役割だけを持ちます。#1で作った基本のpage.jsを軽く整理します。

src/app/page.js

src/app/page.js
import Link from 'next/link';
import ProductCard from './components/ProductCard';
import { getAllProducts } from './lib/products';

export default function HomePage() {
  const featured = getAllProducts().slice(0, 4);

  return (
    <main className="mx-auto max-w-5xl px-4 py-8">
      <section className="rounded-lg bg-gray-100 p-8 text-center">
        <h1 className="text-3xl font-bold">うちのショップ</h1>
        <p className="mt-2 text-gray-600">学習用ショップビルド  良い商品が待っています</p>
        <Link
          href="/products"
          className="mt-4 inline-block rounded-md bg-black px-5 py-2 text-white"
        >
          すべての商品を見る
        </Link>
      </section>

      <section className="mt-8">
        <h2 className="text-xl font-bold">おすすめ商品</h2>
        <ul className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
          {featured.map((p) => (
            <li key={p.slug}>
              <ProductCard product={p} />
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

「おすすめ商品」はひとまず先頭の4つをそのまま切り出して使います。実サービスならfrontmatterにfeatured: trueのようなフラグを置くか、別途キュレーションデータを持ちますが、学習段階ではこの程度で十分です。

共通レイアウト — ヘッダー #

複数のページにわたって同じヘッダーが見えるべきなので、layout.jsにヘッダーを置きます。#1で作ったRootLayoutを補強します。

src/app/layout.js

src/app/layout.js
import Link from 'next/link';
import './globals.css';

export const metadata = {
  title: {
    default: 'うちのショップ',
    template: '%s | うちのショップ',
  },
  description: '学習用ショップビルド',
};

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <header className="border-b">
          <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
            <Link href="/" className="font-bold">うちのショップ</Link>
            <nav className="flex gap-4 text-sm">
              <Link href="/products">商品</Link>
              <Link href="/cart">カート</Link>
            </nav>
          </div>
        </header>
        {children}
      </body>
    </html>
  );
}

カートの数量バッジはあえて空けておきました。カートの状態はクライアントで管理するので、次の記事でClient Componentを別に差し込む形で処理します。

動作確認 #

pnpm devでサーバーを立ち上げて見て回ります。

カテゴリチップをクリックするとURLが変わり、グリッドが絞り込まれます。リロードしてもURLの?category=が同じ状態をそのまま復元します。Server Component + クエリストリングの組み合わせの自然な結果です。

おわりに #

今回の記事ではカタログの2つのページを完成させました。

  • 価格フォーマットのユーティリティを作成
  • カードコンポーネントを分離して一覧とホームで共有
  • /productssearchParamsでカテゴリフィルタを追加
  • /products/[slug]generateStaticParamsnotFound()を適用
  • 共通ヘッダーとホームページを整理

ここまでのコードはすべてServer Componentです。JavaScriptが無効でもカタログを見て回るのに問題はありません。

次の記事から雰囲気が変わります。「Next.jsでショップを作る #3 カート」ではクライアントサイドの状態が登場します。Context API + localStorageでカートを管理し、「カートに入れる」ボタンを動かし、ヘッダーの数量バッジまで生かすところまで進めます。

X