Nextのドキュメントを読みながら、ページネーションつきの検索機能を作ります!
Add search and pagination to your dashbo...
App Router: Adding Search and Pagination...
nextjs.org
作りたいものはこれ

テキストを入力する、またはカテゴリーを選択すると、シュッとデータを取得して結果を表示します。
検索実行ボタンをクリックしたり、エンターキーを押したりする必要はないでりす。
それではいきます!
APIを作る
api/search/route.tsにAPIのルートを作ります。
filter(検索ワード)、category(カテゴリー)、page(ページ番号)をもとにmarkers(検索結果)を返すようにします。
ページネーションを表示したいので、markersと一緒にhitCount(検索にhitした件数)も返すようにします。
import prisma from '../../lib/db';
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const page = Number(req.nextUrl.searchParams.get('page') || '1');
const filter = req.nextUrl.searchParams.get('filter') || '';
const category = req.nextUrl.searchParams.get('category') || '';
const whereClause = {
title: {
contains: filter,
},
...(category && { category: category }),
};
const hitCount = await prisma.marker.count({
where: whereClause,
});
const markers = await prisma.marker.findMany({
where: whereClause,
take: 3,
skip: (page - 1) * 3,
});
return NextResponse.json({
markers,
hitCount,
});
} catch (error) {
console.error('Request error', error);
return NextResponse.json(
{ error: 'Error fetching markers' },
{ status: 500 }
);
}
}ブラウザのurlに「http://localhost:3000/api/search?filter=島」のように入力すると、下記のように検索結果のレスポンスが返ってくるようになります。

ページを作る
page.tsxにSearchコンポーネント(ユーザーが入力する部分)とTableコンポーネント(検索結果)を並べます。
import MarkerLists from '../components/MarkerLists';
import Search from '../components/Search';
import { Suspense } from 'react';
export default async function StaticPage() {
return (
<div>
<Search />
<Table />
</div>
);
}検索機能を作る
検索の状態管理には、useSearchParamsを使います。
useStateでも検索機能は作れますが、useSearchParamsだと検索結果をブックマークできたり、リロード時に検索結果を保持できたり等のメリットがありいい感じです。
検索機能のざっくりした流れ
- ユーザーの入力内容をパラメータとして保持する
- 保持したパラメータを用いてurlを更新する
- 検索データを取得する
下記のようにSeach.tsxを作ります。
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function EnhancedSearch() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearchInput(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('filter', term);
} else {
params.delete('filter');
}
params.set('page', '1');
replace(`${pathname}?${params.toString()}`);
}
function handleSearchCategory(term: string) {
const params = new URLSearchParams(searchParams);
if (term !== 'all') {
params.set('category', term);
} else {
params.delete('category');
}
params.set('page', '1');
replace(`${pathname}?${params.toString()}`);
}
return (
<div>
<div>
<Search />
<Input
placeholder="検索..."
onChange={(e) => handleSearchInput(e.target.value)}
defaultValue={searchParams.get('filter') ?? ''}
aria-label="検索入力"
/>
</div>
<Select
onValueChange={handleSearchCategory}
defaultValue={searchParams.get('category') ?? 'all'}
>
<SelectTrigger>
<SelectValue placeholder="カテゴリー選択" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">select category</SelectItem>
<SelectItem value="観光">観光</SelectItem>
<SelectItem value="食事">食事</SelectItem>
<SelectItem value="家族">家族</SelectItem>
</SelectContent>
</Select>
</div>
);
}inputのonChangeイベントで、ユーザーの入力を保持します。
useSearchParamsで、現在いるページのパラメータを{page: '1', filter: '島'}のように取得します。
usePathnameで、現在いるページのurlを「http://localhost:3000」のように取得します。
params.set('filter', term)のところで、ユーザーの入力をパラメータにセットします。
useRouterのreplaceを使ってurlを更新します。
defaultValueでinputの値とパラメータの値を同期させます。
Searchの役目は、ユーザーの入力をurlに反映させることだよ
検索結果を表示させる
urlにセットしたパラメータは、他のコンポーネントに使いまわすことができます。
Searchコンポーネントはクライアント側のコンポーネントなのでuseSearchParamsを使用していましたが、
Tableコンポーネントはサーバー側でデータを取得するコンポーネントなのでsearchParamsを使用します。
page.tsxでパラメータを取得し、値をTableに渡します。
Suspenseのkeyにパラメータを指定することで、パラメータが変更された時にTableを再描画するようにできます。
import MarkerLists from '../components/MarkerLists';
import Search from '../components/Search';
import { Suspense } from 'react';
export default async function StaticPage(props: {
searchParams?: Promise<{
filter?: string;
category?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const filter = searchParams?.filter || '';
const category = searchParams?.category || '';
const currentPage = Number(searchParams?.page) || 1;
return (
<div>
<Search />
<Suspense key={filter + category + currentPage} fallback={<div>loading...</div>}>
<Table filter={filter} category={category} page={currentPage} />
</Suspense>
</div>
);
}Tableコンポーネントでは、page.tsxから受け取ったパラメータを元にデータを取得します。
また、hitCountをPaginationコンポーネントに渡します。
export default function MarkerLists({
filter,
category,
page,
}: {
filter: string;
category: string;
page: number;
}) {
const { data, isLoading, isError, error } = useQuery<SearchResponse, Error>({
queryKey: ['searchMarkers', { page, filter, category }],
queryFn: () => searchMarkers({ page, filter, category }),
});
...
return (
<div>
<div>
{data.markers.map((marker) => (
...
</div>
<Pagination hitCount={data.hitCount} />
</div>
);
}
データを取得する関数はこんな感じです。引数で受け取ったパラメータを、urlに含めてAPIと通信しています。
const searchMarkers = async ({
page,
filter,
category,
}: {
page: number;
filter: string;
category: string;
}) => {
try {
const res = await fetch(
`/api/search?page=${page}&filter=${filter}&category=${category}`
);
if (!res.ok) {
throw new Error(`Failed to fetch markers: ${res.status}`);
}
const data: SearchResponse = await res.json();
return data;
} catch (error) {
console.error('Error in fetchMarkers:', error);
throw error;
}
};ページネーションを作る
Paginationコンポーネントがやっていることはシンプルです!
urlのパラメータのpageのところを、クリック先のページ番号に書き変えています。
...
export default function Pagination({ hitCount }: PaginationProps): JSX.Element {
const { replace } = useRouter();
const pageVolume = 3;
const totalPages = Math.ceil(hitCount / pageVolume);
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const isLast = hitCount <= currentPage * pageVolume;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
const handlePrevPageClick = (): void => {
replace(createPageURL(currentPage - 1));
};
const handleNextPageClick = (): void => {
replace(createPageURL(currentPage + 1));
};
const handleButtonPageClick = (index: number) => {
replace(createPageURL(index + 1));
};
return (
<div>
<button
onClick={handlePrevPageClick}
disabled={currentPage === 1}
>
<ChevronLeft />
</button>
<ul>
{Array.from({ length: totalPages }, (_, index) => (
<li key={index}>
{currentPage === index + 1 ? (
<span>
{index + 1}
</span>
) : (
<Button
onClick={() => handleButtonPageClick(index)}
>
{index + 1}
</Button>
)}
</li>
))}
</ul>
<button
onClick={handleNextPageClick}
disabled={isLast}
>
<ChevronRight />
</button>
</div>
);
}TableコンポーネントからもらったhitCountは、総ページ数を算出するのに使っています。
Array.from({ length: totalPages }, (_, index) => ...) でページ番号を並べることができます。
完成した

今まで作った検索機能で一番いい感じのができました🙌
