Shu Digital Works

Articles

投稿記事

Top > Articles > プログラミング > Next.js × Rails|SSR+CSRで実装するECサイトの商品一覧ページネーション

Next.js × Rails|SSR+CSRで実装するECサイトの商品一覧ページネーション

プログラミング CSR Next.js Rails SSR ページネーション
公開日 更新日
Next.js × Rails|SSR+CSRで実装するECサイトの商品一覧ページネーション

作成中のポートフォリオがECサイトなので、
商品一覧ページにはページネーションが必要です。

ページネーションには様々なパターンが有り、
自分なりに設計を考えてから実装しました。

リポジトリはこちら
https://github.com/shu915/digital_ichiba_frontend
https://github.com/shu915/digital_ichiba_backend

バージョン情報

フロントエンド

  • TypeScript 5.9.2
  • React 19.1.0
  • Next.js 15.5.0
  • Auth.js 5.0.0-beta.29
  • TailwindCSS 4.1.12

バックエンド

  • Ruby 3.3.9
  • Rails 8.0.2.1
  • Postgresql 16.10
  • Docker 28.4.0

設計について考える

ボタンについて考えてみます。

1ページ目と、最後のページは必要だと思いました。

ページを切り替えても、
ページネーションのアイテムの個数が
固定なら便利だと思いました。
しかし、お店によって、商品のページがどれぐらいあるかバラバラなので、
それを実装しようとすると複雑になりすぎます。
ボタンを押すと、ボタンの個数が変わったり、位置がずれることを許容しました。

自分が5ページ目にいて、
2つ隣の3と7ページに飛ぶことはあまりしないと思います。
だから隣接するページは1つまでにしました。

前へ、次へを実装しているサイトが多いのですが
一つ前の番号、一つ次の番号があるので、
前へと次へは不要と考えました。

スマホサイズで見た場合、横に長過ぎると
崩れやすくなります。
ある程度スッキリしているほうがいいです。

よって上記の図のような設計になりました。

データの取得については、
Next.jsにはSSG、SSR、CSRなどのレンダリング手法があり、
最初のページをSSRで、
2ページ以降をCSRでレンダリングするのがいいと思いました。
SSRで初期レンダリングすることでSEOと初期表示を高速化し、
ページネーション以降の操作はCSRで非同期更新してUXを最適化しています。

1ページのアイテム個数は12とします。
一列のアイテムが4個でも、3個でも、2個でも、1個でも、余らずきれいに並ぶからです。

ショップページのpage.tsx

import Shop from "@/types/shop";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import ShopHeader from "@/components/atoms/ShopHeader";
import PageTitle from "@/components/atoms/PageTitle";
import { notFound } from "next/navigation";
import ProductList from "./ProductList";

export default async function ShopPage({
  params,
  searchParams,
}: {
  params: { id: string };
  searchParams: { page?: string };
}) {
  const { id } = params;
  const page = Number(searchParams?.page) || 1;

  const res = await fetch(`${process.env.NEXT_URL}/api/shops/${id}`, {
    method: "GET",
    cache: "no-store",
  });
  const data = await res.json();
  const shop: Shop = data.shop;

  if (!shop) {
    return notFound();
  }

  return (
    <div>
      <ShopHeader shop_header_url={shop.header_url} shop_name={shop.name} />
      <div className="py-8 inner">
        <PageTitle title={shop.name} />
        <div className="flex justify-end mt-4">
          <Button asChild>
            <Link href={`/shops/${id}/profile`}>
              <span className="font-bold">プロフィールを見る</span>
            </Link>
          </Button>
        </div>
        <div>
          <ProductList shop_id={id} page={page} />
        </div>
      </div>
    </div>
  );
}

/src/app/shops/[id]/page.tsx
これはショップのページです。
ショップの情報を取得し、ページ上部で、
ショップのヘッダーイメージ、名前などを配置しています。
ページの下の方で、商品を並べています。
今回はページネーションに関わる部分だけを解説していきます。

import ProductList from "./ProductList";

ProductListは商品とページネーションのコンポーネントです。
page.tsxから呼び出すようにしています。

export default async function ShopPage({
  params,
  searchParams,
}: {
  params: { id: string };
  searchParams: { page?: string };
}) {

ShopPageは引数でpropsからの分割代入で、
paramsとsearchParamsを引き取ります。。
paramsはショップのidを取得するために必要です。
searchParamsはURLの?page=1のようなpageを取得するのに必要です。
型のほうでpage?となっているのは、指定されない場合もありうるからです。

const { id } = params;
const page = Number(searchParams?.page) || 1;

paramsからidを分割代入しています。
searchParamsにpageがあればそれをNumberで数字に変換して、
なければ1ページとしてpageに代入します。

<ProductList shop_id={id} page={page} />

ProductListというコンポーネントにidとpageを渡します。

初期データを取得する

src/app/shops/[id]/ProductList.tsx

import ProductListClient from "./ProductListClient";
import ProductListType from "@/types/productList";
import { notFound } from "next/navigation";

export default async function ProductList({
  shop_id,
  page,
}: {
  shop_id: string;
  page: number;
}) {
  const res = await fetch(
    `${process.env.NEXT_URL}/api/products?shop_id=${shop_id}&page=${page}`,
    {
      method: "GET",
      cache: "no-store",
    }
  );
  if (!res.ok) {
    return notFound();
  }
  const initialProducts: ProductListType = await res.json();
  return (
    <ProductListClient
      shop_id={shop_id}
      initialProducts={initialProducts}
      initialPage={page}
    />
  );
}

解説します。

import ProductListClient from "./ProductListClient";
import ProductListType from "@/types/productList";
import { notFound } from "next/navigation";

CSRをするためのコンポーネントをインポートします。
ページネーションのボタンを押したときの処理をします

ProductListの型をインポートしています。

import ProductType from "./product";

type ProductListType = {
  products: ProductType[];
  total_items: number;
};

export default ProductListType;

型はこのようになっており、
商品の配列と、
最後のページを算出するためのtotal_itemsがあります。
ProductTypeは商品の型ですが、割愛します。

notFoundは404に飛ばしてくれる機能です。

export default async function ProductList({
  shop_id,
  page,
}: {
  shop_id: string;
  page: number;
}) {

page.tsxから渡ってきたshop_idとpageを引き取ります。

const res = await fetch(
    `${process.env.NEXT_URL}/api/products?shop_id=${shop_id}&page=${page}`,
    {
      method: "GET",
      cache: "no-store",
    }
  );

shop_idとpageをクエリパラメータとして、
Next.jsのBFFのProxyにfetchをします。
Proxyを通過して、RailsのAPIサーバーへGETしに行きます。
Proxyについてはこちら

if (!res.ok) {
  return notFound();
}

res.okでなければ、
とりあえず404に飛ばします。

const initialProducts: ProductListType = await res.json();

帰ってきたresをjson()でオブジェクトに変換して、
initialProductsに代入します。
先ほどのProductListTypeを型に使います。

return (
  <ProductListClient
    shop_id={shop_id}
    initialProducts={initialProducts}
    initialPage={page}
  />
);

return()の中で、
ProductListClientにshop_id, initialProducts, initialPageを渡します。
initialPageがあるのは、
ブラウザのURLに手動でpage=2みたいに入れても動くようにするためです。

クライアントでページネーションを制御する

src/app/shops/[id]/ProductListClient.tsx

"use client";

import { useState, useEffect, useRef } from "react";
import ProductType from "@/types/product";
import ProductListType from "@/types/productList";
import ProductCard from "@/components/organisms/ProductCard";
import Pagination from "@/components/organisms/Pagination";
import { useRouter } from "next/navigation";

export default function ProductListClient({
  shop_id,
  initialProducts,
  initialPage,
}: {
  shop_id: string;
  initialProducts: ProductListType;
  initialPage: number;
}) {
  const router = useRouter();
  const isFirstRender = useRef(true);

  const [page, setPage] = useState<number>(initialPage);
  const [products, setProducts] = useState<ProductType[]>(
    initialProducts.products
  );
  const [totalItems, setTotalItems] = useState<number>(initialProducts.total_items);

  useEffect(() => {
    router.replace(`?page=${page}`, { scroll: false });
  }, [page, router]);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return; // 初回はSSRのデータをそのまま使う
    }
    (async () => {
      try {
        const res = await fetch(
          `/api/products?shop_id=${shop_id}&page=${page}`,
          {
            method: "GET",
            cache: "no-store",
          }
        );
        const data: ProductListType = await res.json();
        setProducts(data.products);
        setTotalItems(data.total_items);
      } catch (e) {
        console.error(e);
      }
    })();
  }, [shop_id, page]);

  return (
    <div>
      <div className="grid grid-cols-4 gap-2 mt-4">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
      <div className="mt-8">
        <Pagination
          page={page}
          setPage={setPage}
          totalItems={totalItems}
          perPage={12}
        />
      </div>
    </div>
  );
}

解説します。

"use client";

これはクライアントコンポーネントです。

import { useState, useEffect, useRef } from "react";
import ProductType from "@/types/product";
import ProductListType from "@/types/productList";
import ProductCard from "@/components/organisms/ProductCard";
import Pagination from "@/components/organisms/Pagination";
import { useRouter } from "next/navigation";

useState, useEffect, useRefをReactからインポートします。

ProductTypeとProductListTypeで、型をインポートします。
ProductCardは、商品のコンポーネントです。

ページネーションもインポートしておきます。

useRouterをnext/navigationからインポートしておきます。
これは、ページネーションでページが切り替わったとき、
ブラウザのURLを書き換えるのに使います。

export default function ProductListClient({
  shop_id,
  initialProducts,
  initialPage,
}: {
  shop_id: string;
  initialProducts: ProductListType;
  initialPage: number;
}) {

渡されたshop_id、initialProducts、initialPageを引き取ります。
initialProductsにはProductListTypeを当てます。

const router = useRouter();

useRouter()を実行すると、ルーターのインスタンスが返ってくるので、
routerに代入しておきます。

const isFirstRender = useRef(true);

これは後ででてくるuseEffectの中で、
初回レンダリングの際は CSR 用のフェッチを実行しないようにするためのフラグです。

const [page, setPage] = useState(initialPage);

initialPageをuseStateの初期値として、pageとsetPageを定義します。

const [products, setProducts] = useState<ProductType[]>(
    initialProducts.products
  );

ProductType[]で、配列としてuseStateの型を渡します。
initialProductsの中にproductsとtotal_itemsが入っているので、
initialProducts.productsを初期値として渡します。
productsとsetProductsを定義します。

const [totalItems, setTotalItems] = useState<number>(initialProducts.total_items);

initialProducts.total_itemsを初期値として、
totalItemsとsetTotalItemsを定義します。

useEffect(() => {
    router.replace(`?page=${page}`, { scroll: false });
  }, [page, router]);

初回レンダリング時に1回実行されます。
その後はpageの値が書き換えられると、
router.replaceが働いて、urlのpage=の値を書き換えます。
多くのサイトではページネーションのボタンを押すと、
ページ番号を切り替えるとブラウザがページ最上部までスクロールしてしまうことがありますが、
scroll: false を指定することでスクロールを抑制します。

router を内部で参照しているため、
React の依存配列のルールに従って router を第二引数に含めています。

useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return; // 初回はSSRのデータをそのまま使う
    }

2個目のuseEffectです。
初回レンダリングのときはスキップして、
フラグをfalseにすることで、
useEffectの第二引数の中の値が変わった場合、
動くようにします。

(async () => {
  try {
    const res = await fetch(
      `/api/products?shop_id=${shop_id}&page=${page}`,
      {
        method: "GET",
        cache: "no-store",
      }
    );
    const data: ProductListType = await res.json();
    setProducts(data.products);
    setTotalItems(data.total_items);
  } catch (e) {
    console.error(e);
  }
})();

() => {} のようなアロー関数を宣言し、
続けて () を付けることで即座に実行する “即時実行関数(IIFE)” です。
useEffect 内で async/await を使いたい時によく用いられる書き方です。

その中でtry-catch文を書いてます。

shop_idとpageをクエリパラメータとして渡して、
相対パスで、Next.jsのBFFのProxyにfetchをしています。
Proxyを通過するとRailsにGETで届きます。

帰ってきたものを.json()でオブジェクト化して
dataに代入します。
setProductsとsetTotalItemsでセットします。

エラーが出た場合は、catchしてコンソールに出します。

}, [shop_id, page]);

useEffectの第二引数です。
shop_idかpageが変わると、あらためてfetchします。
shop_idがあるのはshop1からshop2に切り替えたとき、
shop1の商品が残らないようにするためです。
pageはページネーションによる切り替えで、
商品を入れ替えるためです。

return (
  <div>
    <div className="grid grid-cols-4 gap-2 mt-4">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>

ここではproductsに商品の情報が入っているので、
mapを使って、ProductCardに情報を渡して、
カードを並べています。
ProductCardは割愛します。

    <div className="mt-8">
      <Pagination
        page={page}
        setPage={setPage}
        totalItems={totalItems}
        perPage={12}
      />
    </div>
  </div>

Paginationのコンポーネントに、
page、setPage、totalItems、perPageを渡しています。
perPageはここでは12にしますが、
別のところでは10にするかもしれないので、
こちらから渡します。

ページネーションのファイル

src/components/organisms/Pagination.tsx

import { Button } from "@/components/ui/button";

export default function Pagination({
  page,
  setPage,
  totalItems,
  perPage,
}: {
  page: number;
  setPage: (page: number) => void;
  totalItems: number;
  perPage: number;
}) {
  const totalPages = Math.ceil(totalItems / perPage);

  if (totalItems > 0) {
    return (
      <div className="flex gap-3 justify-center">
        {page !== 1 && (
          <Button onClick={() => setPage(1)} className="bg-gray-500 w-10 h-10">
            {1}
          </Button>
        )}
        {page > 2 && (
          <div className="flex items-center justify-center w-10 h-10">...</div>
        )}
        {page > 2 && (
          <Button
            onClick={() => setPage(page - 1)}
            className="bg-gray-500 w-10 h-10"
          >{`${page - 1}`}</Button>
        )}
        <Button
          onClick={() => setPage(page)}
          className="w-10 h-10"
        >{`${page}`}</Button>
        {page < totalPages - 1 && (
          <Button
            onClick={() => setPage(page + 1)}
            className="bg-gray-500 w-10 h-10"
          >{`${page + 1}`}</Button>
        )}
        {page < totalPages - 1 && (
          <div className="flex items-center justify-center w-10 h-10">...</div>
        )}
        {page !== totalPages && (
          <Button
            onClick={() => setPage(totalPages)}
            className="bg-gray-500 w-10 h-10"
          >{`${totalPages}`}</Button>
        )}
      </div>
    );
  }
  return null;
}

解説します。

import { Button } from "@/components/ui/button";

shadcn-uiを使っていますが、
ボタンは自作でも構いません。

export default function Pagination({
  page,
  setPage,
  totalItems,
  perPage,
}: {
  page: number;
  setPage: (page: number) => void;
  totalItems: number;
  perPage: number;
}) {

呼び出し元から渡された引数とその型です。

const totalPages = Math.ceil(totalItems / perPage);

totalItemsがアイテムの個数です。
perPageは1ページに何個アイテムがあるかです。
ceilをすると、切り上げるので、
totalitems / perPageをしたものをceilすることで、
ページが何ページあるか算出しています。
それをtotalPagesに代入します。

if (totalItems > 0) {

アイテムがない場合は、
表示させないようにしています。

return (
  <div className="flex gap-2 justify-center">
    {page !== 1 && (
      <Button onClick={() => setPage(1)} className="bg-gray-500 w-10 h-10">
        {1}
      </Button>
    )}

1つ目のボタンです。
現在 1 ページ目の場合は、左端の「1」ボタンは不要なので表示しません。
クリックするとsetPage()現在のページを1にします。

{page > 2 && (
  <div className="flex items-center justify-center w-10 h-10">...</div>
)}

左サイドの…を表示するためのものです。
ページが3ページ以上から出現します。

{page > 2 && (
  <Button
    onClick={() => setPage(page - 1)}
    className="bg-gray-500 w-10 h-10"
  >{`${page - 1}`}</Button>
)}

アクティブボタンの一つ左です。
3ページ目から出現します。

<Button
  onClick={() => setPage(page)}
  className="w-10 h-10"
>{`${page}`}</Button>

アクティブなページです。

{page < totalPages - 1 && (
  <Button
    onClick={() => setPage(page + 1)}
    className="bg-gray-500 w-10 h-10"
  >{`${page + 1}`}</Button>
)}

アクティブページから見た次のページです。
アクティブページが最後のページより2少ないところから出現します。
最後が10ページだとするとアクティブページが8ページ目に来ていれば出現します。

{page < totalPages - 1 && (
  <div className="flex items-center justify-center w-10 h-10">...</div>
)}

右サイドの…です。
アクティブページが最後のページより2少ないところから出現します。
最後のページが10ページだとすると、アクティブのページが8に来ていれば出現します。

{page !== totalPages && (
  <Button
    onClick={() => setPage(totalPages)}
    className="bg-gray-500 w-10 h-10"
  >{`${totalPages}`}</Button>
)}

最後のページです。
現在のページが最終ページの場合は、この右端のボタンを非表示にします。

Rails側のロジック

#略
def index
    if params[:shop_id]
      scope = Product.all
      scope = scope.where(shop_id: params[:shop_id])

      total_items = scope.count
      page = params[:page].to_i
      page = 1 if page < 1
      products = scope.offset((page - 1) * 12).limit(12).with_attached_image

      render json: {
        products: products.map { |product| product_json(product) },
        total_items: total_items
        }, status: :ok
    else
      #略

app/controllers/api/products_controller.rbです。
解説します。

indexにルーティングされたとき、
shop_idがない場合は割愛します。
shop_idがある場合、以下の処理に入ります。

scope = Product.all
scope = scope.where(shop_id: params[:shop_id])

scopeというのはActiveRecordのデータ取得の範囲を決めています。
shop_idが一致するものに絞り込みます。

total_items = scope.count

scopeから該当するショップのアイテムの個数を数えて、
total_itemsに代入します。

page = params[:page].to_i
page = 1 if page < 1

Next.jsから渡ってきたpageを数字に変えてから、pageに代入します。
0やマイナスが渡った場合は1とします。

products = scope.offset((page - 1) * 12).limit(12).with_attached_image

offsetでスキップする個数を算出します。
1ページにつき12個スキップします。
limitで12個、productのインスタンスを取ってきます。
.with_attached_imageで画像も取ってきます。
productsに代入します。

render json: {
  products: products.map { |product| product_json(product) },
  total_items: total_items
}, status: :ok

product_jsonというのはconcernsで作ったシリアライザーで、
is_detailを付与すると、個別ページ用にjsonに情報を追加して返しますが、
ここでは割愛します。
要するに、productsの中のインスタンスを、jsonにして必要なものを
Next.jsに返しています。
total_itemsも返します。

まとめ

今回の実装では、Next.js(SSR+CSR)とRails APIを連携させて、
ECサイトの商品一覧ページにシンプルで使いやすいページネーションを構築しました。

最初のページをSSRで描画し、
2ページ目以降をCSRで切り替えることで、
SEO・UX・パフォーマンスのバランスを取った構成を実現しています。

Rails側ではscopeでデータ取得範囲を定義し、
offsetとlimitでページごとのデータを効率的に取得。

Next.js側では、SSRで初期データを取得し、
CSRでページ切り替えを行うシンプルな流れを構築しました。

ページネーションの設計では、

  • 1ページ目と最終ページの固定表示
  • 隣接ページのみの表示
  • 「前へ」「次へ」ボタンを省略

といった方針で、UIの見やすさと実装のわかりやすさを両立しています。

関連記事

2025年6月の学習の振り返り

2025年6月の学習の振り返り

プログラミング

デザインの基礎とUI/UXとGo言語を学習。自分のメンテナンスをしました。

2025年4月の学習の振り返り

2025年4月の学習の振り返り

プログラミング

4月は、AWS、自動デプロイ、Terraformなどのスキルを学習。インフラの理解が深まる。

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

プログラミング

Next.jsとRails間のAPI通信をBFFで共通化。JWTやCookie制御も含めた実装を詳しく解説。