AstroとmicroCMSで作ったサイトにページネーションを実装する
この記事は 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
作成したサンプルデータは下記リポジトリに置いています。
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とコンテンツの作成まで行います。
今回は以下のようなスキーマでAPIを作成し、サンプル用にいくつかデータを入稿しています。
microCMS JavaScript SDKのインストール
フレームワークからmicroCMSのデータを扱うのに便利なSDK(microcms-js-sdk)が公式から提供されているのでインストールします。
$ npm install microcms-js-sdk
環境変数の設定
プロジェクトのルートディレクトリに.env
ファイルを作成し、microCMSのサービス名とAPIキーを環境変数として設定します。
MICROCMS_SERVICE_DOMAIN=<サービス名>
MICROCMS_API_KEY=<APIキー>
以上で実装の準備が整いました。
APIからデータを取得して表示
ここから、microCMSで作成したAPIからデータを取得してページに表示してみます。
まず、microcms-js-sdk
を使用してAPIからデータを取得するための関数を、以下のようなモジュールとして実装しました。
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つに大きく分かれていて、スクリプト部分で定義された変数はテンプレート部分で{ブラケット}
で括って使用することができます。
---
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]
部分が動的なルーティングとなる)を作成して以下のように実装しました。
---
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.params
のid
を引数にAPIからデータを取得しています。
const { id } = Astro.params;
const cat = await getCat(id as string);
これでhttp://localhost:3000/cats/2022-12-01
(2022-12-01
はid
)にアクセスすると以下のように個別ページが表示されるようになりました。
ページネーション
次に、一覧ページにページネーションを実装します。Astroにはビルトインでページネーションの機能がサポートされています。paginate()
関数を使用することで、前ページ、次ページのURLや総ページ数などページネーション用のプロパティを生成することができます。
分割するページのファイル名は、先程の動的ルーティングと同じように[ブラケット]
で囲みます。今回はsrc/pages/cats/page/[page].astro
ファイルを作成しました。[page]
部分が生成されるページ番号となるので、/cats/page/1
/cats/page/2
といった分割されたページが生成される事となります。実装は以下のようにしました。
---
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が使えるようになります。
{
// ...略
"dependencies": {
+ "@astrojs/svelte": "^1.0.2",
+ "svelte": "^3.55.0"
// ...略
}
}
import { defineConfig } from 'astro/config';
+ import svelte from "@astrojs/svelte";
+ export default defineConfig({
+ integrations: [svelte()]
+ });
コンポーネントの作成
準備ができたので、src
ディレクトリ内に components
ディレクトリを作成してsvelteコンポーネントを記述していきます。
実装は以下のようにしました。
<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>…</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>…</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.prev
とpage.url.next
は値がない場合はundefined
が返るので、そのまま分岐条件として利用しています。中身のコンポーネントの実装は以下のようにしました。
PaginationItem.svelte / PaginationPrevNext.svelte
これを[page].astro
内でインポートしてpage
データをpropsで渡して表示しています。
---
// ...略
+ 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の重要な特徴かもしれません)。
各ホスティングサービスへのデプロイ方法は以下ドキュメントにまとめられています。まとめ
microCMSを利用すると、コンテンツを誰でも操作しやすいUIで管理でき、実装側もとても扱いやすいAPIとなっています。Astroは、ページネーションのような機能をフレームワーク側がサポートしているのがとてもよいなと感じました。静的なWebサイトを作成する選択肢としてとても良いのではないでしょうか。
Advent Calendarに参加させて頂きありがとうございました!
🎅メリークリスマス🎄
Discussion