Lab

Astroの静的サイトで記事一覧をページネーションする――getStaticPaths()で2ページ目以降を生成する

Astroの静的サイトで、記事一覧を1ページ目と2ページ目以降に分けて生成する構成を整理します。getStaticPaths()、slice()、共通Pagerを使い、記事追加時にページファイルを手作業で増やさない実装です。

  • Astro
  • ページネーション
  • Content Collections
  • ルーティング

3分英語物語では、物語一覧を1ページにすべて並べるのではなく、一定件数ごとにページを分けています。

新しい順の一覧は、1ページ目を次のURLにしています。

/stories/

2ページ目以降は、

/stories/page/2/
/stories/page/3/

というURLです。

Astroで静的サイトを作っているため、アクセスされたときにサーバーで一覧を分割しているわけではありません。ビルド時に必要なページ数を計算し、2ページ目以降のURLも静的ページとして生成しています。

この記事では、1ページ目と2ページ目以降をどう分け、記事が増えたときにもページファイルを手作業で追加しなくて済むようにしているかを整理します。

1ページ目は通常の一覧ページとして持つ

3分英語物語では、1ページあたりの表示件数を定数で決めています。

const STORIES_PER_PAGE = 20;

通常の一覧ページでは、Content Collectionから物語を取得して新しい順に並べます。

const allStories = (await getCollection('stories')).sort(
  (a, b) => b.data.number - a.data.number
);

1ページ目に表示するのは、先頭から20件です。

const stories = allStories.slice(0, STORIES_PER_PAGE);

このページを /stories/page/1/ にはせず、一覧の入口である /stories/ 自体を1ページ目として使っています。

/stories/          1ページ目
/stories/page/2/   2ページ目
/stories/page/3/   3ページ目

一覧の代表URLをそのまま最初のページにできるので、別に /page/1/ を持つ必要がありません。

総ページ数は記事数から計算する

ページネーションに必要なのは、現在何ページまで存在するかです。

総ページ数は、全記事数を1ページあたりの件数で割り、端数を切り上げて求めています。

const totalPages = Math.ceil(
  allStories.length / STORIES_PER_PAGE
);

たとえば20件までなら1ページ、21件になれば2ページ必要です。

重要なのは、このページ数をコードへ直接書かないことです。

const totalPages = 4;

のように固定してしまうと、記事が増えるたびにページ数も手作業で修正する必要があります。

Content Collectionの件数から毎回計算すれば、記事追加後のビルドで必要なページ数も更新できます。

2ページ目以降は動的ルートにまとめる

2ページ目以降は、次のファイルでまとめて扱っています。

src/pages/stories/page/[page].astro

ここでAstroの getStaticPaths() を使います。

現在の実装を簡略化すると次の形です。

export async function getStaticPaths() {
  const allStories = (await getCollection('stories')).sort(
    (a, b) => b.data.number - a.data.number
  );

  const totalPages = Math.ceil(
    allStories.length / STORIES_PER_PAGE
  );

  return Array.from(
    { length: Math.max(totalPages - 1, 0) },
    (_, index) => {
      const page = index + 2;

      return {
        params: { page: String(page) },
        props: { page },
      };
    }
  );
}

ここでは1ページ目を生成しません。

totalPages - 1 個の要素を作り、ページ番号は index + 2 から始めています。

そのため、総ページ数が4なら生成対象は、

2
3
4

になります。

1ページ目はすでに /stories/ が担当しているため、動的ルート側では2ページ目以降だけを生成する構成です。

Math.max()で「2ページ目が存在しない場合」も扱う

記事がまだ1ページ分しかない場合、

totalPages - 1

は 0 になります。

現在はさらに、

Math.max(totalPages - 1, 0)

として、配列の長さが負にならないようにしています。

2ページ目が必要なければ、getStaticPaths() が生成対象を返さないだけです。

つまり、ページネーション用のルートを用意したからといって、存在しない /stories/page/2/ まで作られるわけではありません。

各ページではslice()する範囲を変える

getStaticPaths() から受け取ったページ番号を使い、そのページに必要な記事だけを切り出します。

const { page } = Astro.props;
const currentPage = Number(page);

const start =
  (currentPage - 1) * STORIES_PER_PAGE;
const end = start + STORIES_PER_PAGE;

const stories = allStories.slice(start, end);

2ページ目なら、

start = 20;
end = 40;

となり、21件目から40件目までが対象になります。

3ページ目なら、

start = 40;
end = 60;

です。

ページごとに別の一覧データを作るのではなく、同じ全記事配列から表示範囲だけを切り替える形にしています。

前後リンクのURL生成は共通コンポーネントへまとめる

ページ番号の計算と同じくらい、地味に分岐が増えやすいのが「前のページ」「次のページ」のURLです。

3分英語物語では StoriesPager.astro にまとめています。

<StoriesPager
  currentPage={currentPage}
  totalPages={totalPages}
  basePath="/stories"
/>

Pager側では、前後ページのURLを計算します。

const prevHref = currentPage <= 1
  ? null
  : currentPage === 2
    ? `${cleanBasePath}/`
    : `${cleanBasePath}/page/${currentPage - 1}/`;

const nextHref = currentPage < totalPages
  ? `${cleanBasePath}/page/${currentPage + 1}/`
  : null;

少し特殊なのが2ページ目から1ページ目へ戻る場合です。

2ページ目のURLは、

/stories/page/2/

ですが、1ページ目は、

/stories/

です。

そのため、単純にページ番号を1減らして、

/stories/page/1/

とはしていません。

currentPage === 2
  ? `${cleanBasePath}/`
  : `${cleanBasePath}/page/${currentPage - 1}/`

という分岐をPager側へ集約しています。

各一覧ページがこの例外を個別に持たなくて済むようにしています。

最初と最後では存在しないリンクを出さない

1ページ目には、それより前のページがありません。

最終ページには、それより後ろのページがありません。

現在のPagerでは、リンク先がない場合は null にし、クリックできるリンクの代わりに無効状態の要素を表示しています。

const prevHref = currentPage <= 1
  ? null
  : ...;

const nextHref = currentPage < totalPages
  ? ...
  : null;

これによって、存在しないページへのリンクを作らずに済みます。

現在地も、

2 / 4

のように「現在のページ / 総ページ数」として同じコンポーネント内で表示しています。

古い順でも同じPagerを使う

3分英語物語には、新しい順とは別に古い順の一覧もあります。

古い順ではURLを、

/stories/oldest/
/stories/oldest/page/2/

としています。

記事の並べ方は昇順になります。

const allStories = (await getCollection('stories')).sort(
  (a, b) => a.data.number - b.data.number
);

ただし、Pagerの仕組み自体は新しい順と共通です。

<StoriesPager
  currentPage={currentPage}
  totalPages={totalPages}
  basePath="/stories/oldest"
/>

basePath を変えるだけなので、Pager側に「新しい順か古い順か」という条件分岐はありません。

一覧側が並び順を決め、PagerはURL階層だけを受け取るように分けています。

この形なら、一覧の種類が増えてもページ移動の基本処理を再利用できます。

記事を追加してもページファイルは増やさない

この構成で一番重要なのは、記事の追加作業とページネーションの更新作業を分離できることです。

記事が増えて新しいページが必要になった場合でも、

page/2.astro
page/3.astro
page/4.astro

のようなファイルを手作業で増やしません。

追加するのは通常どおりContent CollectionのMarkdown記事です。

次のビルド時に、

  1. getCollection() で現在の記事を取得する
  2. Math.ceil() で必要な総ページ数を求める
  3. getStaticPaths() が必要なページ番号を返す
  4. 各ページが slice() で自分の範囲を表示する

という処理が行われます。

ページネーションのためだけに、記事追加時の手順を増やさない構成です。

静的生成だから向いているケースと、向かないケースがある

この方式では、記事一覧の内容とページ数はビルド時に決まります。

Markdownを追加して再ビルドするコンテンツサイトとは相性がよい一方、アクセスのたびに内容が変わる一覧にはそのまま使えません。

たとえば、利用者が投稿した内容を即時に一覧へ反映するサービスや、サーバー上のデータが頻繁に変化する仕組みなら、リクエスト時のデータ取得や別のページネーション方式を検討する必要があります。

3分英語物語では、記事追加とデプロイをセットで行う静的サイトなので、ページ数もビルド時に確定させる方が構成に合っています。

1ページ目を基準にして、残りだけ自動生成する

現在のページネーションをまとめると、次のようになります。

  1. /stories/ を1ページ目として使う
  2. 1ページあたりの件数を定数で決める
  3. 全記事数から総ページ数を計算する
  4. [page].astro の getStaticPaths() では2ページ目以降だけを生成する
  5. 各ページは slice() で必要な範囲を取り出す
  6. 前後リンクのURL計算は共通Pagerへまとめる
  7. 古い順では basePath とソート方向だけを変えて同じ仕組みを使う

静的サイトでも、ページネーションのために記事数やページ数を手作業で管理する必要はありません。

「1ページ目のURL」と「2ページ目以降を生成するルール」を先に決め、記事数から必要なページをビルド時に計算することで、記事が増えても一覧ページを同じ手順で維持できます。