
ページネーションをしたくて記事を探しましたが、量も少なく、納得できませんでした。
ブログで見つけた記事はほとんどがuseStateとAPIを利用した例ばかりでした。
この方法は簡単に実装できますが、サーバーサイドレンダリングの利点を活用できません。
今日はNext.jsでサーバーサイドレンダリング(SSR)でページネーションを実装しようと思います。
やはり参考にしたのは公式ドキュメントです。
1. SSRページネーションのアイデア
この記事の核心は、ページコンポーネントでクエリストリングを受け取るようにすることです。
クエリストリングを利用すると次のような利点があります。
ブックマークおよびURL共有: 検索パラメータがアドレスバーにあるので、共有やブックマークが可能
サーバーサイドレンダリング: サーバーで直接使用して初期状態をレンダリング可能
分析と追跡: 追加のクライアント側のコードなしでユーザー行動を追跡可能
では、コードを見てみましょう。
propでsearchParamsからクエリストリングをパースできます。
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
return (
<div>
<p>searchParams</p>
<p>currentPage</p>
</div>
);
}もし私が '/test?page=1&query="힘센캥거루"' にアクセスした場合、div内でクエリが表示されるのを見ることができます。
それでは、残るはこのクエリをうまく料理するだけです。
2. 検索ボックスの実装
公式ドキュメントからSearchコンポーネントをそのまま持ってきます。
ここでは検索ワードを入力したときにブラウザのコンソールログに出力するようにしておきました。
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border
border-gray-200 py-[9px] pl-10 text-sm outline-2
placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}このようにした後、私はlayoutコンポーネントに以下のように入れました。
コンポーネントに入れてもよいし、どこに入れても構いません。
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-screen-xl w-full prose">
<div className="flex justify-between">
<div>
<h1>목록</h1>
</div>
<div className="w-80">
<Search placeholder="검색어를 입력하세요" /> // 搜索ボックス
</div>
</div>
<div className="mb-8">
<TagGroup path="/category" tags={tagsArray} />
</div>
<div>{children}</div>
</div>
);
}今、検索ワードを入力してコンソールログを見てみましょう。
すると入力が起こるたびにコンソールログに入力が表示されるのを見ることができます。

これでuseSearchParams, usePathname, useRouterを呼び出してオブジェクトを作成します。
以下のコードとコメントを参考にしてください。
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Search() {
const searchParams = useSearchParams();
// useSearchParams呼び出し
const pathname = usePathname();
// usePathnameで元のurlパスを利用
const { replace } = useRouter();
// useRouterのreplaceで元のアドレス置換
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
// 内蔵関数URLSearchParamsでクエリストリング生成
if (term) {
params.set("query", term); // inputに値があれば再割当
} else {
params.delete("query"); // 値がなければ削除
}
replace(`${pathname}?${params.toString()}`);
// 生成されたクエリストリングの前に元のURLアドレスを加え、アドレスバーのアドレスを置換します。
}
// ...
}このようにコードを書いて検索ボックスに入力するとアドレスが変更されるのを見ることができます。

しかし逆にアドレスバーに '/category?query=이게되는교' と入力して入力するとinputの入力欄には何もないことがわかります。
アドレスバーのクエリと検索入力欄の同期をとるためにinputに以下のようなコードを追加しましょう。
defaultValueを入力後、queryを利用してURLにアクセスするとinput値が同期されています。
<input
// ...
defaultValue={searchParams.get("query")?.toString()}
// ...
/>
ところがここで問題があります。
タイピングすると母音、子音が一つずつ入るたびに変更が起こることです。
検索ボックスに最後の入力が発生した後300ms後に実行されるように修正してみましょう。
これをdebounceといいますが、ライブラリをインストールするのが精神衛生に良いです。
yarn add use-debounceそしてコードを以下のように変更しましょう。
ディバウンスを呼び出し、handleSearch関数をuseDebouncedCallbackでラップします。
最後には300を入れて、最後の入力が実行された後300ms後にアドレスが変更されます。
// ...
import { useDebouncedCallback } from "use-debounce";
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);これでサーバー側では該当するクエリをパースして必要な情報だけを返せば良いのです。
3. サーバーサイド検索機能の実装
サーバーサイドは多分人によって異なると思います。
私の場合は現在各mdxのメタデータをjson形式で保存し使用しています。
それならアドレスバーのqueryを利用してfilterをかけた後コンポーネントに返せば良いのです。
import getPostCategory from "@/utils/getPostCategory";
import CategoryPage from "@/components/blog/CategoryPage";
import { postCardObject } from "@/types/allTypes"; // これは私個人が定義した型です。
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query?.toLowerCase() || "";
const currentPage = Number(searchParams?.page) || 1;
// クエリをパース。まだページネーションは実装されていません。
// 大文字小文字の区別を避けるため全て小文字に変換されています。
let posts: postCardObject[] = await getPostCategory();
// すべてのポストデータを読み込みます。
if (query) {
posts = posts.filter((post) => {
const title = post.data.title.toLowerCase();
return title.includes(query);
});
}
// クエリがある場合にはフィルタをかけpostsを再割り当てします。
// 後でスマートにgetPostCategory内部に入れ、パラメータでqueryを渡します。
return (
<>
<CategoryPage postCategorys={posts} />
</>
); // そしてこれをポストリストとしてレンダリングします。
}
テストしてみたら検索ワードに応じてポストがフィルタされたことが分かります。
こうすれば検索機能は終了です。
4. ページネーションの実装
ページネーションも検索ボックスの実装と同じです。
useSearchParams, useRouter, usePathnameでクリックするページのクエリストリングを渡せば良いのです。
少し違う点があります。
ページに表示するポストの数と総ページをコンポーネントにあらかじめ割り当てる必要があります。
export default async function getPostCategory(
perPagePosts: number,
page = 1,
tag?: string
) {
// ...
const totalPage = Math.ceil(postCategorys.length / perPagePosts);
postCategorys = postCategorys.slice(
0 + perPagePosts * (page - 1),
perPagePosts + perPagePosts * (page - 1)
);
// ...
return { postCategorys, totalPage };
}
ポストデータを読み込む関数で一ページ当たり6件のポストをスライスし総ページ数を返すようにしました。
そして総ページ数で10を割ってページを作ります。
とりあえずLinkで作ってテストしてみましょう。
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
// ...
return (
<div>
<CategoryPage postCategorys={postCategorys} />
<div>
{Array.from({ length: totalPage }, (_, i) => i + 1).map((i) => (
<Link
key={i}
className={`mx-3 bg-gray-200 ${
currentPage === i ? "bg-gray-700 text-white" : ""
}`}
href={`?page=${i}`}
>
page {i}
</Link>
))}
</div>
</div>
);
}

上のリンクをたどるとqueryに応じてポストが提供されるのを見ることができます。
次は'use client'コンポーネントを作ってみましょう。
実装はかなり面倒なのでClaudeに一度任せてみましょう。

あ...甘い...
やはりリアクトはクラウド兄さんですね。
そして一つ目を選んでコードを見ていくつか変更を加えます。

import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Pagination({
totalItems,
itemsPerPage = 10,
className = "",
}: {
totalItems: number;
itemsPerPage: number;
className?: string;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const currentPage = Number(searchParams.get("page")) || 1;
const totalPages = Math.ceil(totalItems / itemsPerPage);
// クリックされたページをアドレスバーのクエリに挿入
function handlePageChange(page: number) {
if (page >= 1 && page <= totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
replace(`${pathname}?${params.toString()}`);
}
}
// レンダリングするページ配列を作成
function renderPageNumbers() {
const pages = [];
const showAroundCurrent = 2;
const startPage: number = Math.max(1, currentPage - showAroundCurrent);
const endPage: number = Math.min(
totalPages,
currentPage + showAroundCurrent
);
// 始まり部分の処理
if (startPage > 1) {
pages.push(1);
if (startPage > 2) {
pages.push("...");
}
}
// 現在のページ周辺の数字
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
// 終わり部分の処理
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push("...");
}
pages.push(totalPages);
}
return pages;
}
// return以下は適当に見ても良さそうです。
return (
<div className={`flex items-center justify-center space-x-2 ${className}`}>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
{renderPageNumbers().map((page, index) => (
<button
key={index}
onClick={() => typeof page === "number" && handlePageChange(page)}
disabled={page === "..."}
className={`
px-4 py-2 rounded-lg transition-colors duration-200
${typeof page === "number" ? "hover:bg-gray-100" : ""}
${
page === currentPage
? "bg-blue-500 text-white hover:bg-blue-600"
: ""
}
${page === "..." ? "cursor-default" : "cursor-pointer"}
disabled:cursor-not-allowed
`}
aria-label={typeof page === "number" ? `Page ${page}` : "More pages"}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
aria-label="Next page"
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
);
}このようにしてからcategoryのlayoutにテスト用にページネーションを入れました。
任意で総ポストの数は100個、一ページあたりのポスト数は6個にしてみました。
// ...
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
// ...
<div>
<Pagenation totalItems={100} itemsPerPage={6} />
</div>
<div>ページネーションテスト中</div>
//...
}
問題なくうまく動作しています。
それならサーバーサイドからクライアントコンポーネントにtotalItemとitemsPerPageさえ正しく渡せれば終わりです。
もちろん、先に例示したコードでgetPostCategoryは少し変更するのがおまけです。
5. 後記
ページネーションをどう実装すればいいか非常に悩んでいましたが、一発で終わりました。
最新のトレンドは、本より公式ドキュメントが優れているという意見です。
私の記事が助けになれば幸いです。
댓글을 불러오는 중...