🛹

Supabase & Next.js で検索機能をできるだけシンプルに実装する

13 min read

はじめに

SupabaseNext.jsで実装する検索機能が割とシンプルでいい感じに実装できたのでまとめてみます。なお、コード中にChakra UIやReact Hook Formを用いている部分がありますが、趣旨とズレるので解説しません。

つくるもの

クチコミサイトでの検索を模して検索を実装します。
商品のProductモデルは以下の通りです。

Product.ts
export type Product = {
  id: number;
  created_at: string;
  name: string;
  type: string;
  rate: number;
  price: number;
  brand_id: number;
};

nameカラムとtypeカラムに対して検索を行います。nameカラムに対してキーワード検索を行い、typeはセレクトボックスから選んで商品を絞り込みます。

検索結果一覧ページではページネーションを行うことができます。

また、「新着順」,「価格が高い順」,「価格が安い順」, 「評価が高い順」のように表示順を指定することができます。

検索フォームを実装する

まず検索フォームを実装します。

components/SearchForm.tsx
import { VFC } from 'react';
//略

type SearchForm = {
  keyword: string;
  type: string;
};

export const SearchForm: VFC = () => {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SearchForm>();
  const router = useRouter();

  const onSubmit: SubmitHandler<SearchForm> = (data) => {
      router.push({
        pathname: '/products',
        query: { keyword: data.keyword, type: data.type },
      });
  };

  return (
    <>
      <Flex px={2} alignItems="center">
        <Icon as={FcSearch} w={7} h={7} mr="2" />
        <Heading size="md" color="gray.600">
          検索
        </Heading>
      </Flex>
      <Divider />
      <Box py="5">
        <Controller
          name="keyword"
          control={control}
          render={({ field }) => (
            <Input
              {...field}
              placeholder="キーワードを入力"
              variant="filled"
              size="lg"
              borderRadius="full"
            />
          )}
        />
      </Box>
      <Box pb="5">
        <Text mb={2}>種類</Text>
        <Controller
          name="type"
          control={control}
          render={({ field }) => (
            <Select {...field}>
              <option value="未選択">未選択</option>
              <option value="デッキ">デッキ</option>
              <option value="トラック">トラック</option>
              <option value="ウィール">ウィール</option>
              <option value="ベアリング">ベアリング</option>
              <option value="デッキテープ">デッキテープ</option>
            </Select>
          )}
        />
      </Box>
      <Box pb="10">
        <Button
          onClick={handleSubmit(onSubmit)}
          isFullWidth
          color="white"
          bg="gray.900"
          _hover={{ bg: 'gray.500' }}
        >
          検索
        </Button>
      </Box>
    </>
  );
};

特筆すべき点は特に無いです。検索ボタンを押した際に/products?keyword=aaa?type=デッキのようなパスに飛ばします。

const onSubmit: SubmitHandler<SearchForm> = (data) => {
      router.push({
        pathname: '/products',
        query: { keyword: data.keyword, type: data.type },
      });
  };

商品一覧ページを実装する

次に商品一覧ページを作成します。今回はSSRで実装します。SGで実装することは無いと思いますが、CSRで実装する場合はまた違った書き方になりそうです。

pages/products/index.tsx
import type { NextPage, GetServerSideProps } from 'next';
//略

type Props = {
  Products: Product[];
  keyword?: string;
  type?: string;
  page: number;
  totalCount: number;
};

const PAGE_SIZE = 10;

const ProductIndex: NextPage<Props> = ({ Products, keyword, type, page, totalCount }) => {
  const router = useRouter();
  const onClick = (index: number) => {
    router.push({
      pathname: '/products',
      query: { keyword: keyword, type: type, page: index },
    });
  };

  return (
    <>
	<BreadcrumbNav
        lists={[
          { name: 'HOME', href: '/' },
          { name: `${keyword ? `${keyword}` : '全て'}${type ? type : '商品'}`, href: `#` },
        ]}
      />
      <Stack bg="white" px={4} py={6} shadow="lg" borderRadius="lg">
        <Flex px={2} alignItems="center" justify="space-between">
          <Flex alignItems="center">
            <Icon as={FcSearch} w={7} h={7} mr="2" />
            <Heading size="md" color="gray.600">
              {`${keyword ? `${keyword}` : '全て'}${type ? type : '商品'}`}
            </Heading>
          </Flex>
          <Text color="gray.600">{`${totalCount}`}</Text>
        </Flex>
      </Stack>
      <Box mt={5}>
        <Link href="/search" passHref>
          <Flex alignItems="center" color="blue.600" cursor="pointer">
            <ArrowLeftIcon w={3} h={3} />
            <Text ml={1}>検索画面に戻る</Text>
          </Flex>
        </Link>
      </Box>
      <Stack mt={5} w="full" spacing={4}>
        {Products.map((Product) => {
          return <ProductListItem key={Product.id} product={Product} />;
        })}
      </Stack>
      {totalCount === 0 && (
        <Box>
          <Text fontWeight="bold" color="gray.700" fontSize="lg">
            検索条件に一致する商品はありませんでした。
          </Text>
        </Box>
      )}
      <Pagination
        totalCount={totalCount}
        pageSize={PAGE_SIZE}
        currentPage={page}
        onClick={onClick}
      />
    </>
  );
};

export default ProductIndex;

export const getServerSideProps: GetServerSideProps = async (context) => {
  const keyword = context.query.keyword;
  const type = context.query.type;
  const page = context.query.page ? +context.query.page : 1;

  let query = supabase.from('products').select(
    `
      *,
    brands (
      *
    )
    `,
    { count: 'exact' }
  );
  if (keyword) {
    query = query.like('name', `%${keyword}%`);
  }
  if (type) {
    query = query.eq('type', type);
  }

  const startIndex = (page - 1) * PAGE_SIZE;
  query = query.range(startIndex, startIndex + (PAGE_SIZE - 1));
  
  const { data, error, status, count } = await query;

  if (error && status !== 406) {
    throw error;
  }

  return {
    props: {
      Products: data,
      keyword: keyword,
      type: type,
      page: page,
      totalCount: count ? count : 0,
    },
  };
};

長々と書いてますが、注目してほしい部分はgetServerSideProps内の処理です。queryからkeywordとtypeを取り出し、それを元にデータを取得します。

SupabaseではConditional Chainingという書き方で条件に合わせて、クエリを繋げて実行することができます。これが結構便利です。

後にソート機能を実装する際もこれを用います。

https://supabase.com/docs/reference/javascript/using-filters#conditional-chaining
  let query = supabase.from('products').select(
    `
      *,
    brands (
      *
    )
    `,
    { count: 'exact' }
  );
  if (keyword) {
    query = query.like('name', `%${keyword}%`);
  }
  if (type) {
    query = query.eq('type', type);
  }

  const startIndex = (page - 1) * PAGE_SIZE;
  query = query.range(startIndex, startIndex + (PAGE_SIZE - 1));
  
  const { data, error, status, count } = await query;

ページネーションを実装するために、データの総数を取得しておきます。

let query = supabase.from('products').select(
    `
      *,
    brands (
      *
    )
    `,
    { count: 'exact' }
  );

今回はnameカラムに対してLIKE検索を行いますが、textSearchメソッドを利用すれば全文検索も実装できそうです。

https://supabase.com/docs/reference/javascript/textsearch

Paginationコンポーネントを実装する

ページネーションの実装はmicroCMSさんの記事を参考にしています。(ありがとうございます!)

https://blog.microcms.io/next-pagination/
一応コードはこんな感じです。
components/Pagination.tsx
import { VFC } from 'react';
import { HStack, Button } from '@chakra-ui/react';

type Props = {
  totalCount: number;
  pageSize: number;
  onClick: (index: number) => void;
  currentPage: number;
};

const range = (start: number, end: number) => [...Array(end - start + 1)].map((_, i) => start + i);

export const Pagination: VFC<Props> = ({ totalCount, pageSize, currentPage, onClick }) => {
  return (
    <HStack w="full" justifyContent="center" align="center" py={5}>
      {range(1, Math.ceil(totalCount / pageSize)).map((number, index) => (
        <Button
          as="a"
          key={index}
          onClick={() => onClick(number)}
          bg={currentPage === number ? 'blue.500' : 'blue.100'}
          color={currentPage === number ? 'white' : 'blue.600'}
          _hover={{
            bg: 'blue.300',
            color: 'white',
          }}
          borderRadius="full"
        >
          {number}
        </Button>
      ))}
    </HStack>
  );
};

相違点としてはPropsにページネーションのボタンを押した際に発火する関数であるonClickを渡しています。

渡している関数は下記の通りで、/products?keyword=aaa?type=デッキ?page=2のようにクエリをつけて商品一覧ページに飛ばします。

const onClick = (index: number) => {
    router.push({
      pathname: '/products',
      query: { keyword: keyword, type: type, page: index },
    });
  };

そして、getServerSideProps内でPAGE_SIZE分ずつデータを取得しています。

const startIndex = (page - 1) * PAGE_SIZE;
query = query.range(startIndex, startIndex + (PAGE_SIZE - 1));

ソート機能を実装する

pages/products/index.tsxに追記します。

pages/products/index.tsx
 export const getServerSideProps: GetServerSideProps = async (context) => {
  const keyword = context.query.keyword;
  const type = context.query.type;
  const page = context.query.page ? +context.query.page : 1;
+  const sort = context.query.sort ? context.query.sort : 'new';

  let query = supabase.from('products').select(
    `
      *,
    brands (
      *
    )
    `,
    { count: 'exact' }
  );
  if (keyword) {
    query = query.like('name', `%${keyword}%`);
  }
  if (type) {
    query = query.eq('type', `${type}`);
  }
+  if (sort === 'new') {
+    query = query.order('created_at', { ascending: false });
+  } else if (sort === 'rate') {
+    query = query.order('rate', { ascending: false });
+  } else if (sort === 'higher') {
+    query = query.order('price', { ascending: false });
+  } else if (sort === 'lower') {
+    query = query.order('price', { ascending: true });
+  }

  const startIndex = (page - 1) * PAGE_SIZE;
  query = query.range(startIndex, startIndex + (PAGE_SIZE - 1));
  
  const { data, error, status, count } = await query;

 if (error && status !== 406) {
    throw error;
  }

  return {
    props: {
      Products: data,
      keyword: keyword,
      type: type,
      page: page,
      totalCount: count ? count : 0,
+      sort: sort,
    },
  };
};

Conditional Chainingを利用して?sort=hogehogeの文字列によってorderメソッドの引数を変えます。

上から

  • 投稿が新しい順
  • 評価が高い順
  • 価格が高い順
  • 価格が低い順
    です。

表示順を切り替えるコンポーネントはこんな感じです。

SortMenu.tsx
import { VFC } from 'react';
import {
  Menu,
  MenuButton,
  MenuList,
  MenuItemOption,
  MenuOptionGroup,
  Button,
} from '@chakra-ui/react';
import { FcGenericSortingDesc } from 'react-icons/fc';

type Props = {
  onSortChange: (value: string) => void;
  currentSortType: string;
};

const SortMap: { [key: string]: string } = {
  new: '新着順',
  higher: '価格が高い順',
  lower: '価格が安い順',
  rate: '評価が高い順',
};

export const SortMenu: VFC<Props> = ({ onSortChange, currentSortType }) => {
  const onChange = (value: string | string[]) => {
    if (typeof value !== 'string') return;
    onSortChange(value);
  };
  return (
    <Menu>
      <MenuButton
        as={Button}
        rightIcon={<FcGenericSortingDesc />}
        color="gray.600"
        bg="white"
        shadow="lg"
        borderRadius="lg"
      >
        {SortMap[currentSortType]}
      </MenuButton>
      <MenuList>
        <MenuOptionGroup defaultValue={currentSortType} title="表示順" type="radio" onChange={onChange}>
          {Object.keys(SortMap).map((key, i) => {
            return (
              <MenuItemOption value={key} key={i}>
                {SortMap[key]}
              </MenuItemOption>
            );
          })}
        </MenuOptionGroup>
      </MenuList>
    </Menu>
  );
};

ページネーションと同じように、値が変わった時に発火する関数をPropsとして受け取ります。
渡す関数↓

const onSortChange = (value: string) => {
    router.push({
      pathname: '/products',
      query: { keyword: keyword, type: type, sort: value },
    });
  };

完成!

おわりに

よく比較されるであろうFirestoreは使ったことは無いでのですが、ドキュメントをざっと眺めてみたところ、SupabaseのDatabaseの方が直感的で使いやすそうだなあという印象です。

Supabaseはドキュメントはもちろん、サンプルコードやチュートリアルが充実しているのも利点です。

https://supabase.com/docs/guides/with-nextjs

一方でSupabaseでは開発途中な機能も多そうなのでこれからに期待です!それでは良いお年を🎍

https://firebase.google.com/docs/firestore

https://supabase.com/

Discussion

ログインするとコメントできます