Next.jsで高速eコマースアプリを構築する | エピソード3: 商品詳細ページとSEO最適化

こんにちは！前回のエピソードでは、Shopify Storefront APIをNext.jsに統合し、商品一覧ページ（PLP）を構築しました。今回は、商品詳細ページ（PDP: Product Detail Page）を作成し、SEO最適化や画像の遅延読み込みを追加します。Next.jsの動的ルーティングやメタタグの設定を通じて、検索エンジンでの可視性とパフォーマンスを向上させましょう！

## このエピソードのゴール

- 動的ルーティングを使ってPDPを構築。
- `getStaticPaths`と`getStaticProps`で静的生成（SSG）を活用。
- Next.js Imageコンポーネントで画像を最適化。
- メタタグ（Open Graph、Twitter Cards）を追加してSEOを強化。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- Shopify Storefront APIのアクセストークンとエンドポイント。
- 基本的なTypeScript、React、GraphQLの知識。

---

## ステップ1: 商品詳細データのクエリ作成

PDPでは、商品の詳細情報（説明、複数画像、バリエーションなど）を取得する必要があります。`src/lib/queries.ts`に以下のGraphQLクエリを追加します：

```ts
export const GET_PRODUCT_BY_HANDLE_QUERY = `
  query GetProductByHandle($handle: String!) {
    productByHandle(handle: $handle) {
      id
      title
      handle
      descriptionHtml
      priceRange {
        minVariantPrice {
          amount
          currencyCode
        }
      }
      images(first: 5) {
        edges {
          node {
            url
            altText
          }
        }
      }
      variants(first: 10) {
        edges {
          node {
            id
            title
            price {
              amount
              currencyCode
            }
          }
        }
      }
    }
  }
`;
```

このクエリは、商品のハンドル（URLスラグ）に基づいて詳細データを取得します。商品の説明、複数画像、バリエーション（例: サイズやカラー）を含めます。

次に、`src/lib/shopify.ts`に商品詳細を取得する関数を追加：

```ts
interface ProductDetail {
  id: string;
  title: string;
  handle: string;
  descriptionHtml: string;
  priceRange: {
    minVariantPrice: {
      amount: string;
      currencyCode: string;
    };
  };
  images: {
    edges: Array<{
      node: {
        url: string;
        altText: string | null;
      };
    }>;
  };
  variants: {
    edges: Array<{
      node: {
        id: string;
        title: string;
        price: {
          amount: string;
          currencyCode: string;
        };
      };
    }>;
  };
}

interface ProductDetailResponse {
  productByHandle: ProductDetail | null;
}

export async function getProductByHandle(handle: string): Promise<ProductDetail | null> {
  const data = await shopifyClient.request<ProductDetailResponse>(GET_PRODUCT_BY_HANDLE_QUERY, { handle });
  return data.productByHandle;
}
```

この関数は、指定したハンドルの商品データを取得し、型安全に返します。

---

## ステップ2: 動的ルーティングの設定

Next.jsの動的ルーティングを使って、PDPのURLを`/products/[handle]`として構築します。`src/app/products/[handle]/page.tsx`ファイルを作成し、以下のコードを追加：

```tsx
import { getProductByHandle } from '@/lib/shopify';
import Image from 'next/image';
import { notFound } from 'next/navigation';

interface ProductDetail {
  id: string;
  title: string;
  handle: string;
  descriptionHtml: string;
  priceRange: {
    minVariantPrice: {
      amount: string;
      currencyCode: string;
    };
  };
  images: {
    edges: Array<{
      node: {
        url: string;
        altText: string | null;
      };
    }>;
  };
  variants: {
    edges: Array<{
      node: {
        id: string;
        title: string;
        price: {
          amount: string;
          currencyCode: string;
        };
      };
    }>;
  };
}

interface ProductPageProps {
  params: { handle: string };
}

export async function getStaticPaths() {
  // 実際のプロジェクトでは、すべての商品ハンドルを取得
  // ここではサンプルとして空のリストを使用
  return {
    paths: [],
    fallback: 'blocking', // ISRで動的生成
  };
}

export async function getStaticProps({ params }: ProductPageProps) {
  const product = await getProductByHandle(params.handle);
  if (!product) {
    return { notFound: true };
  }
  return {
    props: {
      product,
    },
    revalidate: 60, // ISR: 60秒ごとに再生成
  };
}

export default function ProductPage({ product }: { product: ProductDetail }) {
  if (!product) return notFound();

  return (
    <main className="container mx-auto p-4">
      <div className="grid md:grid-cols-2 gap-8">
        {/* 画像ギャラリー */}
        <div className="space-y-4">
          {product.images.edges.map((image, index) => (
            <Image
              key={index}
              src={image.node.url}
              alt={image.node.altText || product.title}
              width={500}
              height={500}
              className="w-full h-auto object-cover rounded"
              priority={index === 0} // 最初の画像は優先読み込み
            />
          ))}
        </div>
        {/* 商品情報 */}
        <div>
          <h1 className="text-3xl font-bold mb-4">{product.title}</h1>
          <p className="text-2xl text-gray-700 mb-4">
            {parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2)}{' '}
            {product.priceRange.minVariantPrice.currencyCode}
          </p>
          <div
            className="prose mb-6"
            dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
          />
          <div className="mb-6">
            <h2 className="text-lg font-semibold mb-2">バリエーション</h2>
            <select className="border p-2 rounded w-full">
              {product.variants.edges.map((variant) => (
                <option key={variant.node.id} value={variant.node.id}>
                  {variant.node.title} - {parseFloat(variant.node.price.amount).toFixed(2)}{' '}
                  {variant.node.price.currencyCode}
                </option>
              ))}
            </select>
          </div>
          <button className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90">
            カートに追加
          </button>
        </div>
      </div>
    </main>
  );
}
```

このコードは：
- `getStaticPaths`で動的ルートを定義（`fallback: 'blocking'`でISRを有効化）。
- `getStaticProps`で商品データを取得し、商品が存在しない場合は404を返します。
- Tailwind CSSとNext.js Imageを使ってレスポンシブなPDPを構築。
- 商品の説明を`descriptionHtml`で安全にレンダリング。

**注意**: `getStaticPaths`ではサンプルとして空のリストを使用しています。本番では、すべての商品ハンドルを事前に取得する必要があります。

---

## ステップ3: SEO最適化

SEOを強化するため、メタタグ（Open Graph、Twitter Cards）を追加します。`src/app/products/[handle]/page.tsx`の先頭に以下のコードを追加：

```tsx
import Head from 'next/head';

// ... 既存のコード ...

export default function ProductPage({ product }: { product: ProductDetail }) {
  if (!product) return notFound();

  const ogImage = product.images.edges[0]?.node.url || '';

  return (
    <>
      <Head>
        <title>{product.title} | Next.js eCommerce</title>
        <meta name="description" content={product.descriptionHtml.slice(0, 160)} />
        <meta property="og:title" content={product.title} />
        <meta property="og:description" content={product.descriptionHtml.slice(0, 160)} />
        <meta property="og:image" content={ogImage} />
        <meta property="og:type" content="product" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={product.title} />
        <meta name="twitter:description" content={product.descriptionHtml.slice(0, 160)} />
        <meta name="twitter:image" content={ogImage} />
      </Head>
      <main className="container mx-auto p-4">
        {/* 既存のメインコンテンツ */}
      </main>
    </>
  );
}
```

このコードは：
- ページのタイトルと説明を動的に設定。
- Open GraphとTwitter Cardsのメタタグを追加して、ソーシャルメディアでの共有を最適化。
- 商品の最初の画像をOG画像として使用。

---

## ステップ4: 画像最適化

Next.js Imageコンポーネントは、自動リサイズや遅延読み込みを提供します。上記のコードでは：
- `width`と`height`を指定して画像のサイズを最適化。
- `priority`を最初の画像に設定してLCP（Largest Contentful Paint）を改善。
- `object-cover`で画像のアスペクト比を維持。

さらに、`next.config.js`を更新してShopifyの画像ドメインを許可：

```js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['cdn.shopify.com'],
  },
};

module.exports = nextConfig;
```

これで、Shopifyの画像が正しく読み込まれます。

---

## ステップ5: 動作確認

1. 開発サーバーを起動（`npm run dev`）。
2. `http://localhost:3000/products/商品のハンドル`にアクセス（例: `/products/t-shirt`）。
3. 以下の点を確認：
   - 商品のタイトル、価格、説明、画像が正しく表示される。
   - バリエーションの選択肢が表示される。
   - ページのメタタグが正しく設定されている（デベロッパーツールで確認）。
   - 画像が遅延読み込みされ、レスポンシブに表示される。

エラーがあれば、Shopify APIのレスポンスやハンドルを確認してください。

---

## まとめと次のステップ

このエピソードでは、動的ルーティングと`getStaticProps`を使ってPDPを構築し、SEO最適化と画像最適化を実装しました。Next.js Imageやメタタグの設定により、パフォーマンスと検索エンジンの可視性が向上しました。

次回のエピソードでは、カート機能を構築し、React ContextまたはZustandを使って状態管理を行います。商品の追加や数量変更の処理も実装しますので、引き続きお楽しみに！ 

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！