1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

こんにちは!前回のエピソードでは、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公式サイトでアカウントを作成。
    • 新しいアプリケーションを作成し、インデックス(例: products)を追加。
    • APIキー(Application ID、Search-Only API Key、Admin API Key)を取得。
  2. 環境変数の設定
    .env.localにAlgoliaのAPIキーを追加:

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

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

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

npm install algoliasearch @algolia/client-search

src/lib/algolia.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ファイルを作成:

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のインデックスに保存します。プロジェクトの初期化時や商品更新時に実行します。ターミナルで以下を実行:

npx ts-node src/lib/syncToAlgolia.ts

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


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

商品一覧ページを更新し、検索バーとフィルタリング機能を追加します。src/app/page.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ファイルを作成:

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最適化も紹介しますので、引き続きお楽しみに!


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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?