Supabase & Next.js で検索機能をできるだけシンプルに実装する
はじめに
SupabaseとNext.jsで実装する検索機能が割とシンプルでいい感じに実装できたのでまとめてみます。なお、コード中にChakra UIやReact Hook Formを用いている部分がありますが、趣旨とズレるので解説しません。
つくるもの
クチコミサイトでの検索を模して検索を実装します。
商品のProductモデルは以下の通りです。
export type Product = {
id: number;
created_at: string;
name: string;
type: string;
rate: number;
price: number;
brand_id: number;
};
nameカラムとtypeカラムに対して検索を行います。nameカラムに対してキーワード検索を行い、typeはセレクトボックスから選んで商品を絞り込みます。
検索結果一覧ページではページネーションを行うことができます。
また、「新着順」,「価格が高い順」,「価格が安い順」, 「評価が高い順」のように表示順を指定することができます。
検索フォームを実装する
まず検索フォームを実装します。
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で実装する場合はまた違った書き方になりそうです。
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という書き方で条件に合わせて、クエリを繋げて実行することができます。これが結構便利です。
後にソート機能を実装する際もこれを用います。
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メソッドを利用すれば全文検索も実装できそうです。
Paginationコンポーネントを実装する
ページネーションの実装はmicroCMSさんの記事を参考にしています。(ありがとうございます!)
一応コードはこんな感じです。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
に追記します。
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メソッドの引数を変えます。
上から
- 投稿が新しい順
- 評価が高い順
- 価格が高い順
- 価格が低い順
です。
表示順を切り替えるコンポーネントはこんな感じです。
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はドキュメントはもちろん、サンプルコードやチュートリアルが充実しているのも利点です。
一方でSupabaseでは開発途中な機能も多そうなのでこれからに期待です!それでは良いお年を🎍
Discussion