Next.jsとSupabaseで全文検索を実装する方法

今回はNext.jsとSupabaseを利用し、テーブルの全文検索を行うことができるアプリを作成します。

実はこの『全文検索』、FirebaseよりもSupabaseのほうが優れている分野の一つです。
FirebaseはNoSQLデータベースを使っているため、ネイティブの全文検索がありません。そのため独自の検索ソリューションを利用する必要があります。
しかしSupabaseはPostgreSQLデータベースを利用しているため、元々全文検索機能が含まれており、『textSearch』関数を利用することで素早く実装も可能です。

では早速作り方を見てみましょう。

目次

Supabaseの事前準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していないければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。


適当なプロジェクト名とデータベースのパスワードを入れて新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:Supabaseのテーブル作成

Supabaseダッシュボード→`Table Editor``New Table`を押して新規テーブルを作成します。
設定は添付画像のような形でお願いします。

名前とRLSの有効チェック

カラム

③:テーブルのサンプルデータ作成

import csvボタンから添付のCSVファイルをインポートしてください
商品テーブル_rows.csv (1.8 kB)

こんな感じでインポートしたデータが画面に反映されていればOKです

④:テーブルのポリシーを作成

`Policies`から`New Policy`をクリックし、新しいポリシーを追加します。
テーブルデータを誰でも取得できる設定にしたいので、下記のように指定します。

Next.jsの準備

①:Next.jsプロジェクトの作成

任意のディレクトリで、

npx create-next-app -e with-supabase

と打ち込み、
対話的にアプリケーションの名前の設定をしてプロジェクトの作成を行いましょう。

次に環境変数の設定を行います。
作成されたNext.jsプロジェクトの直下に`.env.local.example`ファイルがあるので、
このファイルを`.env.local`にリネームし、
Supabaseのサイトの`Project Settings→API`にある、Project URLとAPI Keyをそれぞれ`.env.local`にコピペしてください。

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=APIキー

その後、

npm run dev

を実行して添付のような画面が表示されればプロジェクトの作成成功です。

②:必要なファイルの作成

今回作りたい画面に合わせてプロジェクトのファイル構成を変更しましょう。
現状のファイル構成が下記のようになっているかと思いますが、まずは不要なファイルを削除し、必要なファイルと入れ替えて行きましょう。

不要なファイル削除&入れ替え後

削除するファイルは、

  • app/auth/callback/route.ts
  • app/auth/login/page.tsx
  • componentsフォルダの直下すべて
  • util/supabase直下すべて
  • middleware.ts

になります。

新たに作成するファイルは、

  • components/searchInput.tsx
  • components/textSearch.tsx
  • components/table/tableBody.tsx
  • components/table/tableHeader.tsx
  • types/supabase.ts
  • utils/supabase/supabase.ts

になります。(一旦ファイルを新規作成するのみで大丈夫です。中身は後で作ります)
多いので一個ずつ画像を見ながら作成すると良いと思います。

では、各ファイルの中身を作成する前に下準備から行いましょう。

③:下準備

まずは主要部分とは関係ない部分の実装を進めます。

app/layout.tsx

return内を下記のように修正します。

<html lang="ja" className={GeistSans.className}>
  <body className="bg-background text-foreground">
    <main className="min-h-screen flex flex-col items-center px-2">
      {children}
    </main>
  </body>
</html>

app/page.tsx

こちらも同じようにreturn内を修正するのと、textSearchという今回作成する全文検索コンポーネントを追加します。

import TextSearch from "@/components/textSearch";

export default function Index() {
  return (
    <>
      <h1 className="mb-4 pt-10 text-4xl">全文検索のサンプル</h1>
      <div className="flex-1 w-full flex flex-col items-center pb-10">
        <TextSearch></TextSearch>
      </div>
    </>
  );
}

utils/supabase/supabase.ts

Supabaseのクライアントの共通化してアクセスするための準備を行います

import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

.env.localに入力したURLとAPIキーがここで利用されます。

④:SupabaseのDBの型を生成

まず、CLIでSupabaseにログインします

npx supabase login

ログインが終わったら下記で型を生成します。

npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts

`$PROJECT_REF`にSupabaseダッシュボードの`https://supabase.com/dashboard/project/`以下の文字列を入力して実行してください。

Next.js実装

主要部分の実装を進めます。

components/textSearch.tsx

全文検索を行う部分全体を覆うコンポーネントです。

"use client";
import { supabase } from "@/utils/supabase/supabase";
import { useEffect, useState } from "react";
import { Database } from "@/types/supabase";
import TableHeader from "./table/tableHeader";
import TableBody from "./table/tableBody";
import SearchForm from "./searchForm";

export default function TextSearch() {
  const [productList, setProductList] = useState<
    Database["public"]["Tables"]["商品テーブル"]["Row"][]
  >([]);
  const getAllTableData = async () => {
    const { data, error } = await supabase.from("商品テーブル").select();

    if (error) {
      console.log(error);
      return;
    }

    console.log(data);
    setProductList(data);
  };

  useEffect(() => {
    (async () => {
      await getAllTableData();
    })();
  }, []);

  return (
    <>
      <SearchForm tableData={setProductList}></SearchForm>
      <div className="relative overflow-x-auto overflow-y-auto h-96 shadow-md sm:rounded-lg">
        <table className="w-full text-sm text-left rtl:text-right text-gray-500">
          <thead className="text-xs text-gray-700 uppercase bg-gray-50">
            <tr>
              <TableHeader></TableHeader>
            </tr>
          </thead>
          <tbody>
            {productList.map((item, index) => (
              <tr className="odd:bg-white even:bg-gray-50 border-b" key={index}>
                <TableBody tableData={item}></TableBody>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </>
  );
}

`productList`にテーブルのデータが配列で管理されます。

const [productList, setProductList] = useState<
    Database["public"]["Tables"]["商品テーブル"]["Row"][]
  >([]);

初回データの読み込みは下記を`useEffect`内で実行することで実現しています。

const getAllTableData = async () => {
    const { data, error } = await supabase.from("商品テーブル").select();

    if (error) {
      console.log(error);
      return;
    }

    console.log(data);
    setProductList(data);
  };

components/searchForm.tsx

一番重要な検索フォーム部分の実装です。
見た目は添付画像のような形になります。

import { Dispatch, SetStateAction, useState } from "react";
import { supabase } from "@/utils/supabase/supabase";
import { Database } from "@/types/supabase";

export default function SearchForm(props: {
  tableData: Dispatch<
    SetStateAction<Database["public"]["Tables"]["商品テーブル"]["Row"][]>
  >;
}) {
  const [tableRadio, setTableRadio] = useState("name");
  const [searchMethod, setSearchMethod] = useState("|");
  const [searchText, setSearchText] = useState("");

  const onSubmit = async (event: any) => {
    event.preventDefault();

    // 半角と全角両方
    const searchTexts = searchText.split(/[\s\u3000]+/);
    let result = `'${searchTexts[0]}'`;
    if (searchTexts.length > 1) {
      for (let index = 1; index < searchTexts.length; index++) {
        const element = searchTexts[index];
        result += ` ${searchMethod} `;
        result += `'${element}'`;
      }
    }

    const { data, error } = await supabase
      .from("商品テーブル")
      .select()
      .textSearch(tableRadio, result);

    if (error) {
      console.log(error);
      return;
    }

    props.tableData(data);
  };
  return (
    <form className="max-w-lg mx-auto pb-5" onSubmit={onSubmit}>
      <p>商品情報</p>
      <ul className="flex w-full flex-wrap text-sm font-medium text-gray-900 bg-white pb-5">
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="name"
              type="radio"
              value="name"
              name="product-radio"
              defaultChecked
              onChange={(e) => setTableRadio(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="name"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              商品名
            </label>
          </div>
        </li>
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="count"
              type="radio"
              value="count"
              name="product-radio"
              onChange={(e) => setTableRadio(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="count"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              在庫
            </label>
          </div>
        </li>
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="price"
              type="radio"
              value="price"
              name="product-radio"
              onChange={(e) => setTableRadio(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="price"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              値段
            </label>
          </div>
        </li>
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="category"
              type="radio"
              value="category"
              name="product-radio"
              onChange={(e) => setTableRadio(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="category"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              カテゴリー
            </label>
          </div>
        </li>
      </ul>
      <p>検索方法</p>
      <ul className="flex w-full flex-wrap text-sm font-medium text-gray-900 bg-white">
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="or"
              type="radio"
              value="|"
              name="method-radio"
              defaultChecked
              onChange={(e) => setSearchMethod(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="or"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              OR検索
            </label>
          </div>
        </li>
        <li className="border border-gray-200 rounded-lg m-1">
          <div className="flex items-center px-3">
            <input
              id="and"
              type="radio"
              value="&"
              name="method-radio"
              onChange={(e) => setSearchMethod(e.target.value)}
              className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 focus:ring-2 "
            />
            <label
              htmlFor="and"
              className="w-full py-3 ms-2 text-sm font-medium text-gray-900 "
            >
              AND検索
            </label>
          </div>
        </li>
      </ul>
      <div>
        <label
          htmlFor="search"
          className="mb-2 text-sm font-medium text-gray-900 sr-only"
        >
          Search
        </label>
        <div className="relative">
          <div className="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
            <svg
              className="w-4 h-4 text-gray-500"
              aria-hidden="true"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 20 20"
            >
              <path
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth="2"
                d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
              />
            </svg>
          </div>
          <input
            type="text"
            id="search"
            className="block w-full p-4 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 "
            placeholder="検索欄"
            onChange={(e) => setSearchText(e.target.value)}
            required
          />
          <button
            type="submit"
            className="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 "
          >
            Search
          </button>
        </div>
      </div>
    </form>
  );
}

実装としては下記で状態管理をして、

// ラジオボタンで選択した項目
const [tableRadio, setTableRadio] = useState("name");
// 検索方法
const [searchMethod, setSearchMethod] = useState("|");
// 検索テキスト
const [searchText, setSearchText] = useState("");

formのsubmit時に検索を実行しています

const onSubmit = async (event: any) => {
    event.preventDefault();

    // 半角と全角両方
    const searchTexts = searchText.split(/[\s\u3000]+/);
    let result = `'${searchTexts[0]}'`;
    if (searchTexts.length > 1) {
      for (let index = 1; index < searchTexts.length; index++) {
        const element = searchTexts[index];
        result += ` ${searchMethod} `;
        result += `'${element}'`;
      }
    }

    const { data, error } = await supabase
      .from("商品テーブル")
      .select()
      .textSearch(tableRadio, result);

    if (error) {
      console.log(error);
      return;
    }

    props.tableData(data);
  };

Supabaseでは`textSearch`関数で検索の実行ができます。
https://supabase.com/docs/guides/database/full-text-search?language=js

今回AND検索とOR検索を実装していますが、その他に文字列が直後~何文字後に表示されるかを指定して検索するProximity
検索したくない要素を設定するNegationなどの検索方法を利用することが可能です。

components/table/TableHeader.tsx

テーブルのヘッダー部分です。

export default function TableHeader() {
  return (
    <>
      <th scope="col" className="px-6 py-3">
        商品名
      </th>
      <th scope="col" className="px-6 py-3">
        在庫
      </th>
      <th scope="col" className="px-6 py-3">
        値段
      </th>
      <th scope="col" className="px-6 py-3">
        カテゴリー
      </th>
    </>
  );
}

components/table/TableBody.tsx

テーブルのbody部分です。取得したテーブルデータを親コンポーネントから流し込んでUIに利用しています。

import { Database } from "@/types/supabase";

type Props = {
  tableData: Database["public"]["Tables"]["商品テーブル"]["Row"];
};

export default function TableBody({ tableData }: Props) {
  return (
    <>
      <td className="px-6 py-4">{tableData.name}</td>
      <td className="px-6 py-4">{tableData.count}</td>
      <td className="px-6 py-4">{tableData.price}</td>
      <td className="px-6 py-4">{tableData.category}</td>
    </>
  );
}

実装の確認

これで実装ができたので動くかどうか確認してみましょう。

npm run dev

で起動します。

全文検索のサンプルが表示されます。

まず商品名で検索してみます。
デフォルトのまま検索欄に「リンゴ」と入力すると、リンゴを商品名に含む商品のみ表示されます。

さらに絞り込むために検索方法を「AND検索」に変更して「リンゴ サンふじ」と入力して検索しましょう。
すると商品名に「リンゴ」と「サンふじ」両方を含む要素のみが表示されます。

今度はOR検索機能を実感するために、

  • カテゴリーをチェック
  • OR検索をチェック
  • 検索欄に「果物 電子機器」と入力

して検索してみましょう。
すると、カテゴリーが「果物」または「電子機器」の商品が表示されます。

全文検索に必要な機能が正しく実装されていることが確認できました!

その他参考資料など

今回のgithubはこちらです。

またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。
ぜひこちらもご覧ください!

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次