🦤

AstroとmicroCMSで作ったサイトにページネーションを実装する

2022/12/25に公開

この記事は microCMS Advent Calendar 2022 の25日目の記事です。昨年に引き続き参加させて頂きました。今回は、AstroとmicroCMSで作成したWebサイトにページネーションを実装する内容について書いています。

はじめに

microCMSは日本製のヘッドレスCMSです。日本語の分かりやすい管理画面でコンテンツを管理して、APIベースで簡単にデータを扱うことができます。
AstroはWebサイトを構築するためのフレームワークです。公式サイトのトップに"Build faster websites."とあるように、高速なWebサイトを構築することを目的としていて、完全な静的HTMLを生成することもできるといった特徴があります。また、Astroにはページネーションに必要な機能がビルトインでサポートされています。本記事ではこの機能を利用して、以下のような2種類のページネーションを実装しています。


セットアップ

今回使用したNode.jsと主なパッケージのバージョンは以下の通りです。

  • Node.js - v18.12.1
  • Astro - v1.6.15
  • microcms-js-sdk - v2.3.2
  • Svelte - v3.55.0

作成したサンプルデータは下記リポジトリに置いています。
https://github.com/K-shigehito/astro-microcms-example

Astroのプロジェクト作成

最初にAstroプロジェクトの作成をします。雛形を生成するためのCLIが用意されているので、コマンドを実行して作成します。

$ npm create astro@latest

実行するといくつか質問されますが、今回は下記のようにしました。

  • プロジェクト名 ... astro-microcms
  • 使用するテンプレート ... a few best practices (recommended)
  • npm依存関係をインストールするか ... yes
  • git repositoryの初期化をするか ... yes
  • TypeScriptのsetupをどうするか ... Strict

これでプロジェクトのディレクトリに移動してnpm run devコマンドを実行すると、開発サーバーが立ち上がります。

$ cd astro-microcms
$ npm run dev

 astro  v1.6.2 started in 127ms
┃ Local    http://localhost:3000/
┃ Network  use --host to expose

http://localhost:3000/にアクセスするとテンプレートで用意された初期画面が表示されます。

この時点でのディレクトリ構成は以下のようになっています。

/
├── node_modules/
├── public/
│   └── favicon.svg
├── src/
│   ├── components/
│   │   └── Card.astro
│   ├── layouts/
│   │   └── Layout.astro
│   └── pages/
│   │   └── index.astro
│   └── env.d.ts
├── .gitignore
├── package-lock.json
├── tsconfig.json
├── README.md
└── astro.config.mjs

srcディレクトリにプロジェクトのソースコードを格納します。インストール後にもいくつかのサンプルデータが格納されています。publicディレクトリはビルドプロセスで処理する必要のないファイルやアセットを格納します。astro.config.mjsはAstroの設定ファイルです。使用するインテグレーション、ビルドオプション、サーバーオプションなどを指定できます。

microCMSでコンテンツのデータを用意

次にmicroCMSでコンテンツのデータを用意します。公式のマニュアルを参照して、アカウント登録からAPIとコンテンツの作成まで行います。
https://document.microcms.io/manual/getting-started

今回は以下のようなスキーマでAPIを作成し、サンプル用にいくつかデータを入稿しています。

microCMS JavaScript SDKのインストール

フレームワークからmicroCMSのデータを扱うのに便利なSDK(microcms-js-sdk)が公式から提供されているのでインストールします。

$ npm install microcms-js-sdk

環境変数の設定

プロジェクトのルートディレクトリに.envファイルを作成し、microCMSのサービス名とAPIキーを環境変数として設定します。

.env
MICROCMS_SERVICE_DOMAIN=<サービス名>
MICROCMS_API_KEY=<APIキー>

以上で実装の準備が整いました。

APIからデータを取得して表示

ここから、microCMSで作成したAPIからデータを取得してページに表示してみます。

まず、microcms-js-sdkを使用してAPIからデータを取得するための関数を、以下のようなモジュールとして実装しました。

library/useApi.ts
import { createClient } from "microcms-js-sdk";
import type {
  MicroCMSListResponse,
  MicroCMSQueries,
  MicroCMSImage,
  MicroCMSListContent,
} from "microcms-js-sdk";

export type Cats = {
  title: string;
  date: string;
  image: {
    fieldId: string;
    image: MicroCMSImage;
    alt: string;
  };
  category: string[];
  content: string;
};
export type CatsResponse = MicroCMSListResponse<Cats>;

// clientの作成
const client = createClient({
  serviceDomain: import.meta.env.MICROCMS_SERVICE_DOMAIN,
  apiKey: import.meta.env.MICROCMS_API_KEY,
});

// "cats" APIからデータを取得する関数
export const getCats = async (queries?: MicroCMSQueries) => {
  return await client.get<CatsResponse>({ endpoint: "cats", queries });
};

// "cats" APIからIDを指定して個別データを取得する関数
export const getCat = async (contentId: string, queries?: MicroCMSQueries) => {
  return await client.getListDetail<Cats>({
    endpoint: 'cats',
    contentId,
    queries,
  });
};

型定義は作成したAPIのスキーマと合わせて、microcms-js-sdkから提供されているものを利用しています。createClient()でclientを作成してclient.get()でデータを取得します。引数のendpointにAPI名(ここでは"cats")を指定して、特定のAPIからデータを取得できる設計となっています。

そしてpagesディレクトリ内のindex.astroを書き換えて、APIから取得したデータを表示します。Astroコンポーネントは、上部の---で囲まれたスクリプト部分と、下部のテンプレート部分の2つに大きく分かれていて、スクリプト部分で定義された変数はテンプレート部分で{ブラケット}で括って使用することができます。

index.astro(class属性は省略)
---
import Layout from '@layouts/Layout.astro';
import { getCats } from '@library/useApi';

// getCats() 関数で一覧データを取得
const data = await getCats({ limit: 1000 });
const cats = data.contents;
---

<Layout title="Dear Cats">
  <main>
    <ul>
      {cats.map((cat) => {
        return (
          <li>
            <a href={`/cats/${cat.id}`}>
              <img
                src={cat.image.image.url}
                height={cat.image.image.height}
                width={cat.image.image.width}
                alt={cat.image.alt}
              />
              // ...略
            </a>
          </li>
        );
      })}
    </ul>
  </main>
</Layout>

スクリプト部分でgetCats()関数をインポートしてデータを取得しています(Astroコンポーネントのスクリプト部分ではtop-level awaitが利用できます)。引数のqueries{ limit: 1000 }として、とりあえず1000件までデータを取得し、テンプレート部分でリスト表示しています。Astroコンポーネントでは下記のようなJSXに似た記述をすることができます。

{cats.map((cat) => return (<li>...<li/>))}

これで、以下のようにmicroCMSのAPIからデータを取得してルートページにリストを表示することができました。

ルーティングとページネーション

前置きが長くなりましたが、ここから本題のページネーションの実装についてです。

ルーティング

Astroはファイルベースのルーティングを採用しています。src/pages/ディレクトリにAstroコンポーネント(.astro)やMarkdownファイル(.md)を配置すると自動でページ(URL)が生成されルーティングされる仕組みとなっています。

動的にルーティングする場合には[ブラケット]記法を用いてファイルを作成し、getStaticPaths()関数の戻り値として生成するパスの配列を返します。今回は個別ページ用にsrc/pages/cats/[id].astroファイル([id]部分が動的なルーティングとなる)を作成して以下のように実装しました。

src/pages/cats/[id].astro(class属性は省略)
---
import Layout from '@layouts/Layout.astro';
import { getCats, getCat } from '@library/useApi';

// getStaticPaths() 関数で生成するパスの配列を返す
export const getStaticPaths = async () => {
  // APIからコンテンツのidを取得
  const response = await getCats({ fields: ['id'], limit: 1000 });
  // 生成するパスの配列を返す
  return response.contents.map((content) => ({
    params: {
      id: content.id,
    },
  }));
};
// Astro.paramsから各ルーティングのidを取得
const { id } = Astro.params;
const cat = await getCat(id as string);
---

<Layout title={`${cat.title} | Dear Cats`}>
  <main>
    <div>
      <img
        src={cat.image.image.url}
        height={cat.image.image.height}
        width={cat.image.image.width}
        alt={cat.image.alt}
      />
      <div>
      // ...略
    </div>
  </main>
</Layout>

各ルーティングに対してAstro.paramsオブジェクトが利用できます。以下の部分で、Astro.paramsidを引数にAPIからデータを取得しています。

const { id } = Astro.params;
const cat = await getCat(id as string);

これでhttp://localhost:3000/cats/2022-12-012022-12-01id)にアクセスすると以下のように個別ページが表示されるようになりました。

ページネーション

次に、一覧ページにページネーションを実装します。Astroにはビルトインでページネーションの機能がサポートされています。paginate()関数を使用することで、前ページ、次ページのURLや総ページ数などページネーション用のプロパティを生成することができます。

分割するページのファイル名は、先程の動的ルーティングと同じように[ブラケット]で囲みます。今回はsrc/pages/cats/page/[page].astroファイルを作成しました。[page] 部分が生成されるページ番号となるので、/cats/page/1 /cats/page/2といった分割されたページが生成される事となります。実装は以下のようにしました。

src/pages/cats/page/[page].astro(class属性は省略)
---
import Layout from '@layouts/Layout.astro';
import { getCats } from '@library/useApi';
import type { GetStaticPathsOptions } from 'astro';

export const getStaticPaths = async ({ paginate }: GetStaticPathsOptions) => {
  const cats = await getCats({ limit: 1000 });
  return paginate(cats.contents, { pageSize: 3 });
};
const { page } = Astro.props;
---

<Layout title={`page ${page.currentPage} | Dear Cats`}>
  <main>
    <ul>
      {/* page.dataでページデータを表示 */}
      {page.data.map((cat) => {
        return (<li> ...略 </li>);
      })}
    </ul>

    <nav>
      {/* page.currentPageで現在ページ、page.lastPageで総ページ数を表示 */}
      <p>page {page.currentPage}/{page.lastPage}</p>
      <ul>
        {/* page.url.prevがある場合は前ページへのリンクを表示 */}
        {page.url.prev && (
          <li>
            <a href={page.url.prev}>PREV</a>
          </li>
        )}
        {/* page.url.nextがある場合は次ページへのリンクを表示 */}
        {page.url.next && (
          <li>
            <a href={page.url.next}>NEXT</a>
          </li>
        )}
      </ul>
    </nav>
  </main>
</Layout>

getStaticPaths()の引数に{ paginate }を渡します。そしてpaginate()の第一引数にgetCats()で取得したデータを、第二引数に{ pageSize: 3 }を渡して返すことで、1ページに3項目入るように分割されたページが生成されます。

export const getStaticPaths = async ({ paginate }: GetStaticPathsOptions) => {
  const cats = await getCats({ limit: 1000 });
  return paginate(cats.contents, { pageSize: 3 });
};

そして、各ページのデータはpageオブジェクトとして以下のようにして利用できます。

const { page } = Astro.props;

この中から以下のプロパティを使用してリストデータとページネーションを表示しています。

  • page.data -> ページデータの配列
  • page.currentPage -> 1から始まる現在のページ番号
  • page.lastPage -> 最終ページ数
  • page.url.prev -> 前のページのURL
  • page.url.next -> 次のページのURL

これでhttp://localhost:3000/cats/page/1にアクセスすると、以下のように表示され、/page/2 /page/3... と分割されたページを移動できるようになりました。

ページ移動の機能追加

ここまでの実装では、次ページと前ページへの移動しかできないため、最後にもう少し機能を追加してみます。要件は以下のようにしました。

  • 現在ページとその前後ページへのリンクを表示する
  • 最初のページと最後のページへのリンクは常に表示する
  • それ以外のページは「...」として隠す
  • 現在ページが先頭ページ以外の場合に「PREV」リンクを、最終ページ以外の場合に「NEXT」リンクを表示する

分かりにくいですが、図示すると以下のようになります。

UIフレームワークのインストール

Astroは、インテグレーションをインストールすることでReact、Vue、SvelteなどのUIフレームワーク/ライブラリを使用することができます。今回の場合はAstroコンポーネントのみでも実装できますが、例としてSvelteを使用してみます。

導入にはCLIツールが用意されていて、astro addコマンドを実行することでインストールから設定まで行ってくれます。

$ npx astro add svelte

実行すると「設定ファイルの変更確認」と「インストールコマンドの実行確認」の2つ質問されるので両方yキーで続行します。これで以下のようにパッケージの追加と設定ファイルの変更まで完了し、プロジェクト内でSvelteが使えるようになります。

package.json
{
   // ...略
   "dependencies": {
+    "@astrojs/svelte": "^1.0.2",
+    "svelte": "^3.55.0"
     // ...略
   }
 }
astro.config.mjs
 import { defineConfig } from 'astro/config';

+ import svelte from "@astrojs/svelte";

+ export default defineConfig({
+   integrations: [svelte()]
+ });

コンポーネントの作成

準備ができたので、srcディレクトリ内に componentsディレクトリを作成してsvelteコンポーネントを記述していきます。

実装は以下のようにしました。

src/components/PaginationList.svelte(class属性は省略)
<script lang="ts">
  import PaginationItem from "./PaginationItem.svelte";
  import PaginationPrevNext from "./PaginationPrevNext.svelte";
  import type { Page } from "astro";

  // propsで'pageデータ'と'隣接するページを表示する数'を受け取る
  export let page: Page;
  export let adjacentPageNumber: number = 1; // 初期値は1
  // ページ番号の配列を作成
  const pager = [...Array(page.lastPage).keys()].map((i) => ++i);
  // リンク先のパスを生成する関数
  const getPath = (page: number) => {
    return `./${page}`;
  };
</script>

<nav aria-label="pagination">
  <ul>
    <!-- 前ページが存在する場合はPREVリンクを表示する -->
    {#if page.url.prev}
      <li><PaginationPrevNext href={page.url.prev} type={'prev'} /></li>
    {/if}
    <!-- 現在ページが「隣接ページ数 + 1」を超える場合は先頭ページと...を表示する -->
    {#if adjacentPageNumber + 1 < page.currentPage}
      <li>
        <PaginationItem
          currentPage="{page.currentPage}"
          page="{1}"
          href="{getPath(1)}"
        />
      </li>
      <li>&#8230;</li>
    {/if}
    <!-- ページ番号の配列リストから「現在ページ +- 隣接ページ数」のページを表示する -->
    {#each pager as p (p)}
      {#if page.currentPage - adjacentPageNumber <= p && p <= page.currentPage + adjacentPageNumber}
        <li>
          <PaginationItem
            currentPage="{page.currentPage}"
            page="{p}"
            href="{getPath(p)}"
          />
        </li>
      {/if}
    {/each}
    <!-- 現在ページが「最終ページ - 隣接ページ数」の場合...と最終ページを表示する -->
    {#if page.currentPage < page.lastPage - adjacentPageNumber}
      <li>&#8230;</li>
      <li>
        <PaginationItem
          currentPage="{page.currentPage}"
          page="{page.lastPage}"
          href="{getPath(page.lastPage)}"
        />
      </li>
    {/if}
    <!-- 次ページが存在する場合はNEXTリンクを表示する -->
    {#if page.url.next}
      <li><PaginationPrevNext href={page.url.next} type={'next'} /></li>
    {/if}
  </ul>
</nav>

先程作成したページネーション用のpageオブジェクトをpropsで受け取り、テンプレート部分では現在ページpage.currentPageとそれぞれの表示条件を比較して表示を出し分けています。page.url.prevpage.url.nextは値がない場合はundefinedが返るので、そのまま分岐条件として利用しています。中身のコンポーネントの実装は以下のようにしました。

PaginationItem.svelte / PaginationPrevNext.svelte

これを[page].astro内でインポートしてpageデータをpropsで渡して表示しています。

src/pages/cats/page/[page].astro(class属性は省略)
 ---
 // ...略
+ import PaginationList from '@components/UI/PaginationList.svelte';
 ---

 <Layout title={`page ${page.currentPage} | Dear Cats`}>
   <main>
     {/* ...略 */}
+    <PaginationList {page} />
   </main>
 </Layout>

これで以下のようなページネーションの実装ができました!

ビルドしてみる

この状態でnpm run buildを実行し、生成される/dist/index.htmlを確認するとスクリプトが読み込まれていないHTMLが生成されていることが確認できます。

/dist/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content="Astro v1.6.15" />
    <title>Dear Cats</title>
    <link rel="stylesheet" href="/assets/_id_.020a2f68.css" />
  </head>
  <body>
    <div>
      <header>
        <!-- ...略 -->
      </header>
      <div>
        <main>
          <!-- ...略 -->
        </main>
      </div>
      <footer>
        <!-- ...略 -->
      </footer>
    </div>
  </body>
</html>

今回は扱っていませんが、例えばフォーカス制御をJavaScriptを使って実装する場合などは、コンポーネントごとにインタラクティブに動作させることもできます(本来この辺りがAstro Islandsと呼ばれるAstroの重要な特徴かもしれません)。
https://docs.astro.build/ja/concepts/islands/
各ホスティングサービスへのデプロイ方法は以下ドキュメントにまとめられています。
https://docs.astro.build/ja/guides/deploy/

まとめ

microCMSを利用すると、コンテンツを誰でも操作しやすいUIで管理でき、実装側もとても扱いやすいAPIとなっています。Astroは、ページネーションのような機能をフレームワーク側がサポートしているのがとてもよいなと感じました。静的なWebサイトを作成する選択肢としてとても良いのではないでしょうか。

Advent Calendarに参加させて頂きありがとうございました!
🎅メリークリスマス🎄

参考

GitHubで編集を提案

Discussion