Next.jsで高速eコマースアプリを構築する | エピソード7: 商品検索とフィルタリング機能の実装

こんにちは！前回のエピソードでは、Core Web Vitalsを最適化し、Lighthouseスコアを90以上に引き上げました。今回は、ユーザー体験をさらに向上させるために、商品検索とフィルタリング機能を追加します。Algoliaを活用して高速な検索機能を実装し、価格やカテゴリに基づくフィルタリングも構築します。Next.jsとTailwind CSSを使って、直感的な検索UIをデザインしましょう！

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

- AlgoliaをNext.jsに統合して検索機能を実装。
- 価格とカテゴリに基づくフィルタリング機能を追加。
- 検索とフィルタリングの状態を管理（デバウンスを活用）。
- Tailwind CSSでレスポンシブな検索UIを構築。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- Algoliaアカウント（無料プランで十分）。
- `@algolia/client-search`パッケージ。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Algoliaのセットアップ

Algoliaは、高速で柔軟な検索エンジンです。まず、Algoliaアカウントを作成し、プロジェクトを準備します。

1. **Algoliaアカウントの作成**：
   - [Algolia公式サイト](https://www.algolia.com/)でアカウントを作成。
   - 新しいアプリケーションを作成し、インデックス（例: `products`）を追加。
   - APIキー（Application ID、Search-Only API Key、Admin API Key）を取得。

2. **環境変数の設定**：
   `.env.local`にAlgoliaのAPIキーを追加：

```env
NEXT_PUBLIC_ALGOLIA_APP_ID=あなたのアプリケーションID
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=あなたの検索専用APIキー
ALGOLIA_ADMIN_API_KEY=あなたの管理者APIキー
```

---

## ステップ2: Algoliaクライアントの設定

Algoliaと通信するために、クライアントをセットアップします。必要なパッケージをインストール：

```bash
npm install algoliasearch @algolia/client-search
```

`src/lib/algolia.ts`ファイルを作成し、以下のコードを追加：

```ts
import algoliasearch from 'algoliasearch/lite';

const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!;
const searchKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!;

export const algoliaClient = algoliasearch(appId, searchKey);
export const searchIndex = algoliaClient.initIndex('products');
```

このコードは、Algoliaの検索クライアントを初期化し、`products`インデックスにアクセスできるようにします。

---

## ステップ3: 商品データをAlgoliaに同期

Shopifyの商品データをAlgoliaに同期するスクリプトを作成します。`src/lib/syncToAlgolia.ts`ファイルを作成：

```ts
import { getProducts } from './shopify';
import algoliasearch from 'algoliasearch';

const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!;
const adminApiKey = process.env.ALGOLIA_ADMIN_API_KEY!;

const algoliaClient = algoliasearch(appId, adminApiKey);
const index = algoliaClient.initIndex('products');

export async function syncProductsToAlgolia() {
  try {
    const products = await getProducts(100); // 最大100商品を取得
    const objects = products.map((product) => ({
      objectID: product.id,
      title: product.title,
      handle: product.handle,
      price: parseFloat(product.priceRange.minVariantPrice.amount),
      currency: product.priceRange.minVariantPrice.currencyCode,
      image: product.images.edges[0]?.node.url || '',
      categories: product.tags || ['clothing', 'accessories'], // Shopifyのタグまたは仮データ
    }));

    await index.saveObjects(objects);
    console.log('商品がAlgoliaに同期されました');
  } catch (error) {
    console.error('Algolia同期エラー:', error);
  }
}
```

このスクリプトは、Shopifyの商品データを取得し、Algoliaのインデックスに保存します。プロジェクトの初期化時や商品更新時に実行します。ターミナルで以下を実行：

```bash
npx ts-node src/lib/syncToAlgolia.ts
```

**注意**: 本番環境では、Vercel FunctionsやGitHub Actionsを使って定期的な同期をスケジュールしてください。

---

## ステップ4: 検索とフィルタリングUIの構築

商品一覧ページを更新し、検索バーとフィルタリング機能を追加します。`src/app/page.tsx`を以下のように更新：

```tsx
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { searchIndex } from '@/lib/algolia';
import { debounce } from '@/lib/utils';

interface Product {
  objectID: string;
  title: string;
  handle: string;
  price: number;
  currency: string;
  image: string;
  categories: string[];
}

export default function Home() {
  const [query, setQuery] = useState('');
  const [products, setProducts] = useState<Product[]>([]);
  const [priceFilter, setPriceFilter] = useState<number | null>(null);
  const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const fetchProducts = useCallback(
    debounce(async (searchQuery: string, price: number | null, category: string | null) => {
      setLoading(true);
      try {
        const filters = [];
        if (price !== null) filters.push(`price <= ${price}`);
        if (category) filters.push(`categories:${category}`);

        const { hits } = await searchIndex.search<Product>(searchQuery, {
          filters: filters.join(' AND '),
        });
        setProducts(hits);
      } catch (error) {
        console.error('検索エラー:', error);
      } finally {
        setLoading(false);
      }
    }, 300),
    []
  );

  useEffect(() => {
    fetchProducts(query, priceFilter, categoryFilter);
  }, [query, priceFilter, categoryFilter, fetchProducts]);

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">商品一覧</h1>
      {/* 検索バー */}
      <div className="mb-6">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="商品を検索..."
          className="w-full border p-2 rounded focus:outline-none focus:ring-2 focus:ring-primary"
        />
      </div>
      {/* フィルタリング */}
      <div className="flex flex-col sm:flex-row gap-4 mb-6">
        <div>
          <label className="block text-sm font-medium mb-1">価格上限</label>
          <select
            onChange={(e) => setPriceFilter(e.target.value ? Number(e.target.value) : null)}
            className="border p-2 rounded w-full"
          >
            <option value="">すべて</option>
            <option value="5000">5,000 JPY以下</option>
            <option value="10000">10,000 JPY以下</option>
            <option value="20000">20,000 JPY以下</option>
          </select>
        </div>
        <div>
          <label className="block text-sm font-medium mb-1">カテゴリ</label>
          <select
            onChange={(e) => setCategoryFilter(e.target.value || null)}
            className="border p-2 rounded w-full"
          >
            <option value="">すべて</option>
            <option value="clothing">服</option>
            <option value="accessories">アクセサリー</option>
          </select>
        </div>
      </div>
      {/* 商品リスト */}
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
        {loading ? (
          Array(6)
            .fill(0)
            .map((_, index) => (
              <div key={index} className="border rounded-lg p-4 animate-pulse">
                <div className="w-full h-48 bg-gray-200 rounded"></div>
                <div className="h-6 bg-gray-200 mt-2 rounded"></div>
                <div className="h-4 bg-gray-200 mt-2 rounded w-1/2"></div>
              </div>
            ))
        ) : products.length === 0 ? (
          <p className="text-gray-600">商品が見つかりませんでした。</p>
        ) : (
          products.map((product) => (
            <Link href={`/products/${product.handle}`} key={product.objectID}>
              <div className="border rounded-lg p-4 hover:shadow-lg transition">
                <Image
                  src={product.image}
                  alt={product.title}
                  width={200}
                  height={200}
                  className="w-full h-48 object-cover rounded"
                  loading="lazy"
                  style={{ aspectRatio: '1 / 1' }}
                />
                <h2 className="text-xl font-semibold mt-2">{product.title}</h2>
                <p className="text-gray-600">
                  {product.price.toFixed(2)} {product.currency}
                </p>
              </div>
            </Link>
          ))
        )}
      </div>
    </main>
  );
}
```

このコードは：
- 検索バーとフィルタリングオプション（価格、カテゴリ）を追加。
- Algoliaの検索結果をリアルタイムで表示。
- スケルトンUIをローディング中に表示。
- Tailwind CSSでレスポンシブなUIを構築。

---

## ステップ5: デバウンス関数の実装

検索リクエストを制限するために、デバウンス関数を追加します。`src/lib/utils.ts`ファイルを作成：

```ts
export function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
  let timeout: NodeJS.Timeout;
  return (...args: Parameters<T>) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => func(...args), wait);
  };
}
```

この関数は、ユーザーの入力から300ms待機してから検索リクエストを送信し、過剰なAPI呼び出しを防ぎます。

---

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

1. Algoliaに商品データを同期（`syncProductsToAlgolia`を実行）。
2. 開発サーバーを起動（`npm run dev`）。
3. `http://localhost:3000`にアクセスし、以下の点を確認：
   - 検索バーにキーワードを入力すると、関連商品がリアルタイムで表示される。
   - 価格やカテゴリフィルタを選択すると、商品リストが適切に更新される。
   - ローディング中にスケルトンUIが表示される。
   - モバイルでもUIが適切に表示される。
4. デベロッパーツールのネットワークタブで、検索リクエストがデバウンスされていることを確認。

エラーがあれば、AlgoliaダッシュボードでインデックスデータやAPIキーを確認してください。

---

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

このエピソードでは、Algoliaを使って高速な検索とフィルタリング機能を実装しました。デバウンスやスケルトンUIを活用することで、快適で効率的な検索体験を提供できました。

次回のエピソードでは、多言語対応（i18n）を追加し、グローバル市場向けにアプリを拡張します。`next-intl`を使ったルーティングやSEO最適化も紹介しますので、引き続きお楽しみに！

---

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