microCMS

Next.js × microCMSで店舗検索機能を実装する

高宮 竜太

こんにちは、カスタマーエンジニアの高宮です。

今回はNext.jsとmicroCMSを使って店舗検索機能を実装する方法をご紹介いたします。

近年、複数の店舗を運営している事業でのmicroCMSの導入や導入のご相談が増えてきております。

飲食店チェーン、小売業、美容サロンなど、複数の店舗情報を管理するサイトにおいて、ユーザーが簡単に店舗を見つけられることは、顧客満足度向上の鍵となりますので、参考にしていただければ幸いです。

はじめに

今回の実装の全体像は以下の通りです。

  • Next.js 14 App Routerを使用
  • 店舗のデータはmicroCMSで管理
  • キーワード、カテゴリー、都道府県の3種類で複合検索
  • microCMSのfiltersパラメータ、qパラメータを利用して絞り込む
  • 検索結果をGoogle Mapに反映


以下については未実装となっております。

  • Google Map APIのカスタマイズや、細かい挙動の調整
  • 取得した店舗情報を現在地から近い順に表示
  • レスポンシブ対応


完成イメージ

操作感は以下のリンクからお試しください。
https://microcms-store-search.vercel.app/search

実装の流れ

  • APIスキーマ定義
  • 検索ページの作成
  • 検索を行うコンポーネントの作成
  • URLクエリパラメータからデータを取得してクエリを生成
  • microCMSにリクエスト
  • レスポンスをGoogle Mapに出力

APIスキーマの定義

まずは、microCMSのAPIスキーマを定義します。
今回は以下の3つのAPIをリスト形式で作成します。

  • 店舗情報(stores)
  • 都道府県(prefectures)
  • カテゴリー(categories)


それぞれのフィールドは以下のように設定します。

店舗情報(stores)

  • 店舗名
  • 電話番号
  • 都道府県(コンテンツ参照)
  • 住所
  • 緯度
  • 経度
  • カテゴリー(コンテンツ参照)

都道府県(prefectures)

  • 都道府県名
  • 都道府県コード
  • 緯度
  • 経度

カテゴリー(categories)

  • カテゴリー名

検索ページの作成

APIスキーマを定義したらフロントの実装に移ります。
まず、/app/search/page.tsxに検索ページを作成します。

<Search/>コンポーネント:ユーザーが絞り込みを行う際に操作するコンポーネント
<Results/>コンポーネント:URLクエリパラメータから検索キーワードを取得し、検索結果を表示するコンポーネント

// app/search/page.tsx
const Page = () => {
  return (
    <div className="flex h-screen">
      <div className="w-1/4">
        <Suspense  fallback={<Spinner />}>
          <Search />
        </Suspense>
      </div>
      <div className="w-3/4">
         <Suspense fallback={<Spinner />}>
          <Results/>
        </Suspense> 
      </div>
    </div>
  );
};

Searchコンポーネントの作成

<Search/>はServer Componentで検索項目をmicroCMSから取得し、各コンポーネントに渡す役割をしています。

今回は、都道府県、キーワード、カテゴリーの3つの検索項目を実装しますので、それぞれのコンポーネントをインポートしています。

※一画面に収めたかったので、型、APIの取得、検索パラメータの初期化、検索フォームのUI実装を1つのコンポーネントにまとめています。

// app/search/_components/Search.tsx
import { createClient } from "microcms-js-sdk";

const client = createClient({
  serviceDomain: process.env.SERVICE_DOMAIN!,
  apiKey: process.env.API_KEY!,
});

type Prefectures = {
  name: string;
  code: number;
  lat: number;
  lng: number;
}

type Categories = {
  title: string;
}

const PREFECTURES_LIMIT = 47;

const getCategories = async () => {
  return await client.getList<Categories>({
    endpoint: "categories",
  });
};

const getPrefectures = async () => {
  return await client.getList<Prefectures>({
    endpoint: "prefectures",
    queries: { orders: "code", limit: PREFECTURES_LIMIT },
  });
};

const Search = async () => {
  const [{ contents: prefectures }, { contents: categories }] =
    await Promise.all([getPrefectures(), getCategories()]);
  return (
    <section className="h-full w-full border-r border-gray-200 p-8">
      <div className="grid gap-8">
        <h2 className="text-lg font-bold">店舗検索</h2>
        <PrefectureSelect prefectures={prefectures} />
        <KeyWord />
        <CategoryCheckBox categories={categories} />
      </div>
    </section>
  );
};
export default Search;

キーワード検索のコンポーネントを作成

住所や店舗名を入力して店舗を絞り込むためのコンポーネントを作成します。

キーワード検索やその他の検索はインタラクティブな機能を持ち、ブラウザ専用のAPIを使うため、すべてClient Componentで実装します。

また、検索はURLクエリパラメータを利用して行います。
ユーザーが検索項目を入力、選択した際にURLクエリパラメータを更新し、その値を利用してサーバーで検索項目に一致するデータを取得するように実装します。

// app/search/_components/KeyWord.tsx
"use client";
const KeyWord = () => {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const params = new URLSearchParams(searchParams);
    const formData = new FormData(e.currentTarget);
    const q = formData.get("q")?.toString();
    if (q) {
      params.set("q", q);
    } else {
      params.delete("q");
    }
    replace(`${pathname}?${params.toString()}` as Route);
  };

  return (
    <form onSubmit={handleSubmit} className="grid gap-2">
      <label htmlFor="q" className="font-bold">
        キーワードから探す
      </label>
      <div className="flex h-10 w-full items-center rounded-md border border-input bg-background pr-2 text-sm">
        <input
          type="search"
          id="q"
          name="q"
          placeholder="店舗名・住所を入力"
          className="w-full px-3 py-2"
          defaultValue={searchParams.get("q")?.toString()}
        />
        <button aria-label="検索する">
          <SearchIcon className="h-4 w-4 opacity-50" />
        </button>
      </div>
    </form>
  );
};
export default KeyWord;

以下の流れでURLを更新します。

  • ユーザーがキーワードを入力して送信
  • const q = formData.get("q")?.toString():ユーザーが入力したキーワードを取得
  • params.set("q", q):キーワードをクエリパラメータに追加
  • replace(${pathname}?${params.toString()}):URLを更新


例えば、キーワードに東京タワーと入力すると/search?q=東京タワーのようなURLに更新されていれば完了です。

都道府県で絞り込むコンポーネントを作成

続いて、都道府県を選択して店舗を絞り込むためのコンポーネントを実装します。

<Search/>コンポーネントからpropsで受け取った都道府県のデータをセレクトボックスに表示します。

キーワード検索では、入力したキーワードをURLクエリパラメータに追加しましたが、都道府県の場合は選択した都道府県のidを追加します。

また、Google Mapの中心値を選択した都道府県の緯度経度に設定したいため、currentLat,currentLngというkeyでクエリパラメータに追加しておきます。

 // app/search/_components/PrefectureSelect.tsx
"use client";

type Prefectures = {
  name: string;
  code: number;
  lat: number;
  lng: number;
}

const PrefectureSelect = ({ prefectures }: { prefectures: Prefectures[] }) => {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleChangeArea = useCallback(
    (area: string) => {
      const params = new URLSearchParams(searchParams);

      if (area) {
        const selectedPrefecture = prefectures.find((pref) => pref.id === area);
        params.set("area", area);
        if (!selectedPrefecture) return;
        params.set("currentLat", selectedPrefecture.lat.toString());
        params.set("currentLng", selectedPrefecture.lng.toString());
      } else {
        params.delete("area");
        params.delete("currentLat");
        params.delete("currentLng");
      }
      replace(`${pathname}?${params.toString()}` as Route);
    },
    [pathname, replace, searchParams, prefectures]
  );

  return (
    <div className="grid gap-2">
      <label htmlFor="area" className="font-bold">
        エリアから探す
      </label>
      <select
        id="area"
        className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm "
        onChange={(e) => {
          handleChangeArea(e.target.value);
        }}
        defaultValue={searchParams.get("area")?.toString()}
      >
        <option value="">エリアを選択</option>
        {prefectures.map((prefecture) => (
          <option key={prefecture.id} value={prefecture.id}>
            {prefecture.name}
          </option>
        ))}
      </select>
    </div>
  );
};
export default PrefectureSelect;

都道府県を選択をして/search?area=tokyo&currentLat=35.68944&currentLng=139.69167のようなURLになれば完了です。

カテゴリーで絞り込むコンポーネントを作成

最後に、カテゴリーを選択して店舗を絞り込むためのコンポーネントを作成します。
都道府県のコンポーネントと同様に<Search/>コンポーネントで取得したカテゴリーをpropsで受け取って表示します。

カテゴリーも都道府県と同様に選択したカテゴリーのidをクエリパラメータに追加します。
他の項目と異なる点としては、カテゴリーは複数選択できるため、選択したカテゴリーのidをカンマ区切りの文字列でクエリパラメータに追加します。

// app/search/_components/CategoryCheckBox.tsx
"use client";

type Categories = {
  title: string;
}

const CategoryCheckBox = ({ categories }: { categories: Categories[] }) => {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleCheck = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const params = new URLSearchParams(searchParams);
      const value = e.target.value;

      const selectedCategories = new Set(
        params.get("categories")?.split(",") || []
      );

      if (selectedCategories.has(value)) {
        selectedCategories.delete(value);
      } else {
        selectedCategories.add(value);
      }

      if (selectedCategories.size === 0) {
        params.delete("categories");
      } else {
        params.set("categories", Array.from(selectedCategories).join(","));
      }

      replace(`${pathname}?${params.toString()}` as Route);
    },
    [searchParams, pathname, replace]
  );

  return (
    <fieldset>
      <div className="grid gap-2">
        <legend className="font-bold">カテゴリーで絞り込む</legend>
        <div className="flex flex-col space-y-2">
          {categories.map((category) => (
            <div key={category.id} className="flex items-center space-x-2">
              <input
                type="checkbox"
                value={category.id}
                className="peer h-4 w-4 shrink-0 rounded-sm border border-primary "
                id={category.id}
                onChange={handleCheck}
                defaultChecked={searchParams
                  .get("categories")
                  ?.split(",")
                  .includes(category.id)}
              />
              <div className="text-sm">
                <label className="font-medium" htmlFor={category.id}>
                  {category.title}
                </label>
              </div>
            </div>
          ))}
        </div>
      </div>
    </fieldset>
  );
};
export default CategoryCheckBox;

category1,category2が選択された場合、/search?categories=category1,category2のようなURLになります。

ここまでで、検索関連のコンポーネントの実装は完了です。
各コンポーネントを操作して以下のようにURLが更新されていれば次の実装に移りましょう。

URLクエリパラメータのデータを取得する

app/search/page.tsxでsearchParamsというpropsから更新したURLクエリパラメータを受け取り、<Results/>コンポーネントに渡します。

Google Mapの中心位置の初期値を設定するために、currentLatcurrentLngには東京駅の緯度経度を設定しています。
今回は実装していませんが、ユーザーの現在地を初期値にするとユーザーの体験が向上するかもしれません。

// app/search/page.tsx
type Props = {
  q?: string
  area?: string
  categories?: string[]
  currentLat?: string
  currentLng?: string
}
const TOKYO_STATION_LOCATION = { lat: 35.68123620000001, lng: 139.7671256 };
const Page = ({ searchParams = {} }: { searchParams?: Props }) => {
  const {
    q = "",
    area = "",
    categories = [],
    currentLat = TOKYO_STATION_LOCATION.lat.toString(),
    currentLng = TOKYO_STATION_LOCATION.lng.toString(),
  } = searchParams;
  return (
    <div className="flex h-screen">
      <div className="w-1/4">
        <Suspense fallback={<Spinner />}>
          <Search />
        </Suspense>
      </div>
      <div className="w-3/4">
        <Suspense key={area + q + categories} fallback={<Spinner />}>
          <Results
            q={q}
            area={area}
            categories={Array.isArray(categories) ? categories : [categories]}
            currentLat={currentLat}
            currentLng={currentLng}
          />
        </Suspense>
      </div>
    </div>
  );
};

export default Page;

URLパラメータを受け取り、microCMSから店舗情報を取得する

<Results/>コンポーネントでは、受け取ったURLクエリパラメータを使ってmicroCMSから店舗情報を取得・表示します。
buildFilters関数でURLクエリパラメータの値をmicroCMSのfiltersパラメータqパラメータで絞り込みができるように整形します。

参考:GET /api/v1/{endpoint}
https://document.microcms.io/content-api/get-list-contents

// app/search/_components/Results.tsx
import { createClient } from "microcms-js-sdk";

const client = createClient({
  serviceDomain: process.env.SERVICE_DOMAIN!,
  apiKey: process.env.API_KEY!,
});

type Props = {
  q?: string;
  area?: string;
  categories?: string[];
  currentLat?: string;
  currentLng?: string;
};

const buildFilters = (area?: string, categories?: string[]) => {
  const areaFilter = area ? `prefectures[equals]${area}` : "";
  const categoriesFilter = categories?.length
    ? `categories[contains]${categories.join(",")}`
    : "";

  if (areaFilter && categoriesFilter) {
    return `${areaFilter}[and]${categoriesFilter}`;
  }
  return areaFilter || categoriesFilter || "";
};

const getStores = async ({
  q,
  area,
  categories,
  currentLat,
  currentLng,
}: Props) => {
  const filters = buildFilters(area, categories);

  return await client.getAllContents<Stores>({
    endpoint: "stores",
    queries: { q, filters },
  });
};

const Results = async ({ q, area, categories }: Props) => {
  const stores = await getStores({ q, area, categories });

  return (
    <div className="flex h-full">
      <section className="h-full w-1/3 overflow-scroll p-8">
        <div className="grid gap-6 ">
          <h2 className="text-lg font-bold">検索結果</h2>
          {stores.length === 0 ? (
            <p className="">検索結果がありません</p>
          ) : (
            <ul className="grid  gap-3 ">
              {stores.map((store) => (
                <li
                  key={store.id}
                  className="grid gap-2 rounded-md border p-4 transition hover:bg-gray-50"
                >
                  <p className="font-bold">{store.name}</p>
                  <p className="text-sm">{store.address}</p>
                </li>
              ))}
            </ul>
          )}
        </div>
      </section>
      <div className="relative h-full w-2/3">
        {/*
          <Map positions={markerPositions} center={center} />
        */}
      </div>
    </div>
  );
};

export default Results;

処理の流れは以下のとおりです。

  1. propsからURLクエリパラメータのデータを取得
  2. 取得したデータをmicroCMSのクエリパラメータに変換
  3. getStores関数でmicroCMSから該当の店舗情報を取得
  4. 取得した店舗情報を表示
  5. URLクエリパラメータが更新されるたびに1~4の処理を繰り返す


これらの実装が完了したらテストしてみましょう。
検索項目を選択したらURL が更新され、サーバー上でクエリに一致する店舗情報がmircoCMSから取得した後フロントに表示されるはずです。

Google Mapに検索結果を反映させる

最後に取得した店舗情報をGoogle Mapに反映させます。
店舗情報は緯度経度の値を持っているので、検索結果からオブジェクトの配列を作成し、Mapコンポーネントに渡します。
また、先ほど取得した中心地の緯度経度もpropsで渡します。

// app/search/_components/Results.tsx
// ...省略
const Results = async ({ q, area, categories }: Props) => {
  const stores = await getStores({
    q,
    area,
    categories,
    currentLat,
    currentLng,
  });

  const markerPositions = stores?.map((store) => {
    return { lat: store.lat, lng: store.lng };
  });

  const center = { lat: Number(currentLat), lng: Number(currentLng) };

  return (
    <div className="flex h-full">
      // ...省略
      <div className="relative h-full w-2/3">
        <Map positions={markerPositions} center={center} />
      </div>
    </div>
  );
};

export default Results;

<Map/>コンポーネントでは、@react-google-mapsからインポートした<GoogleMap>コンポーネントを使用して、Google Mapを表示し、Map上に店舗の位置情報をマーカーで表示します。

$ npm i @react-google-maps/api 


'use client'
import { FC, memo } from 'react'
import { GoogleMap, MarkerF, useJsApiLoader } from '@react-google-maps/api'
import MapLoading from './MapLoading'

type Props = {
  zoom?: number
  positions?: { lat: number; lng: number }[] | []
  center?: { lat: number; lng: number }
}
const mapContainerStyle = { height: '100%', width: '100%' }
const Map = ({ zoom = 9, positions, center }:   Props) => {
  const { isLoaded } = useJsApiLoader({
    id: 'google-map-script',
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!
  })
  return isLoaded ? (
    <GoogleMap center={center} zoom={zoom} mapContainerStyle={mapContainerStyle}>
      {positions &&
        positions.length > 0 &&
        positions.map((position, index) => <MarkerF key={index} position={position} />)}
    </GoogleMap>
  ) : (
    <MapLoading />
  )
}

export default memo(Map)

ここまでで、検索ページの実装は終了です。
操作して、店舗情報を取得し、Google Mapにピンを表示できれば成功です。

Google Mapは調整などは行っていませんので、デザインや仕様に合わせてお好みでカスタマイズしてください。

microCMS Meetup 2024を開催します!

microCMSの新機能や取り組みを発表する、年に1度のミートアップイベント「microCMS Meetup」を今年も開催します!!🎉🎉

過去2年(2022年, 2023年)はオンラインで開催していましたが、今年は初のオフラインでの開催となります!(ライブ配信あり)

リリース予定の新機能を発表することはもちろん、オフラインの場でmicroCMSのユーザーのみなさまと交流できる貴重な機会をメンバー一同とても楽しみにしています!ぜひ奮ってご参加ください🙌

参加を希望される方は、以下のリンクからお申し込みください。
https://microcms.connpass.com/event/316541/

終わりに

今回はNext.jsとmicroCMSを使って店舗検索機能を実装する方法についてご紹介しました。
Google Mapのカスタマイズやデザインの調整などをすることでより使いやすくなるかと思いますので、ぜひ試してみてください!

なお、リポジトリは公開していますので、ぜひ御覧ください。
https://github.com/wasabi-tr/microcms-store-search

弊社メンバーが執筆した技術書の発売が決定!

この度、弊社のエンジニアが執筆した
Next.js+ヘッドレスCMSではじめる! かんたんモダンWebサイト制作入門 高速で、安全で、運用しやすいサイトのつくりかた
が翔泳社から2024年7月8日(月)に発売されることになりました!

新しいサイト制作のアプローチを身につけたいWeb制作者はもちろん、ステップアップとしてWebフレームワークに触れてみたい人にもおすすめの1冊となっておりますので、ぜひお手にとってください!

書籍の詳細な情報やご購入方法については以下のページをご覧ください!
https://shoeisha.co.jp/book/detail/9784798183664

店舗ごとのコンテンツ運営にお悩みはありませんか?

複数企業や複数店舗にてmicroCMSをご活用いただくケースについて紹介しています。複雑な権限管理やレビューフローの構築が可能です。

資料の詳細を見る

ABOUT ME

高宮 竜太
元小学校教員。Web制作会社のフロントエンドエンジニアを経て現在はmicroCMSのカスタマーエンジニアをしています。趣味はバス釣り🎣