Astro × microCMS で記事一覧・記事詳細・ページ送りを作る|getStaticPaths と fields で静的生成
この回でやること
前回は、Astro のプロジェクトを作り、microCMS から記事を全件取得して、トップページにタイトルを箇条書きで並べるところまで進めました。
今回は、それをブログらしい形にします。作るページは次のとおりです。
URL | 内容 |
|---|---|
| 新着記事(6件) |
| 記事一覧(12件ずつ) |
| 記事一覧の2ページ目以降 |
| 記事詳細 |
どのページもビルド時に HTML として生成します。サーバーでページを組み立てる処理はありません。
この回で増えるファイル
src
├── components
│ ├── ArticleCard.astro # 記事カード(一覧の1件分)
│ ├── ArticleList.astro # 記事カードを並べる
│ └── Pagination.astro # ページ送り
├── consts.ts # サイト名・1ページの件数などの定数
├── layouts
│ └── BaseLayout.astro # 共通レイアウト
├── lib
│ ├── date.ts # 日付の表示形式
│ └── microcms.ts # 一覧用の取得関数を追加
├── pages
│ ├── index.astro # 新着記事
│ └── blog
│ ├── index.astro # 記事一覧の1ページ目
│ ├── [id].astro # 記事詳細
│ └── page
│ └── [page].astro # 記事一覧の2ページ目以降
└── styles
└── global.css1. 共通レイアウトを作る
まず、すべてのページで使うレイアウトを作ります。<head> の中身・ヘッダー・フッターをここにまとめ、ページごとに変わる部分は <slot /> に入れます。
---
import '../styles/global.css';
import { SITE_DESCRIPTION, SITE_NAME } from '../consts';
interface Props {
/** 未指定ならサイト名のみ */
title?: string;
description?: string;
}
const { title, description = SITE_DESCRIPTION } = Astro.props;
const pageTitle = title ? `${title} | ${SITE_NAME}` : SITE_NAME;
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
<meta name="description" content={description} />
</head>
<body>
<!-- ヘッダー・フッターは省略 -->
<main class="container">
<slot />
</main>
</body>
</html>タイトルは「記事タイトル | サイト名」の形にし、title を渡さないページ(トップページ)はサイト名だけにしています。
サイト名や1ページの件数は、あとで変えやすいように定数にしました。
export const SITE_NAME = 'cyber-blow';
export const SITE_DESCRIPTION =
'エラー解決・検証・比較・AIツール活用術など、実際に手を動かして確かめたIT技術情報を発信するブログ。';
/** 記事一覧1ページあたりの表示件数 */
export const POSTS_PER_PAGE = 12;2. 一覧では本文を取得しない
前回作った getAllBlogs は、本文(content)も含めて全記事を取得します。記事詳細を作るには本文が必要ですが、一覧に並べるのはタイトルや日付、アイキャッチだけです。
そこで、一覧用に本文を除いて取得する関数を追加しました。
export type BlogSummary = Omit<Blog, 'content'>;
// 一覧では本文(content)を取得しないことで転送量を抑える
const SUMMARY_FIELDS = [
'id',
'title',
'eyecatch',
'category',
'createdAt',
'updatedAt',
'publishedAt',
'revisedAt',
];
/** 全記事(本文込み)。記事詳細ページの生成に使う */
export function getAllBlogs(): Promise<Blog[]> {
return client.getAllContents<Blog>({
endpoint: 'blogs',
queries: { orders: '-publishedAt' },
});
}
/** 全記事(本文なし)。一覧ページに使う */
export function getBlogSummaries(): Promise<BlogSummary[]> {
return client.getAllContents<BlogSummary>({
endpoint: 'blogs',
queries: { fields: SUMMARY_FIELDS, orders: '-publishedAt' },
});
}microCMS の fields に取得したいフィールドを並べると、その項目だけが返ってきます。本文は記事の中でいちばん大きなデータなので、これを省くだけで一覧の取得がだいぶ軽くなります。
型のほうも Omit<Blog, 'content'> で本文を除いた BlogSummary を作り、一覧のコンポーネントでうっかり本文を使えないようにしています。
3. 記事カードと一覧のコンポーネント
一覧の1件分を表示する記事カードです。アイキャッチ・カテゴリ・公開日・タイトルを出し、カード全体を記事へのリンクにしています。
---
import { formatDate } from '../lib/date';
import type { BlogSummary } from '../lib/microcms';
interface Props {
post: BlogSummary;
}
const { post } = Astro.props;
const publishedAt = post.publishedAt ?? post.createdAt;
---
<article class="card">
<a href={`/blog/${post.id}/`}>
{
post.eyecatch ? (
<img
class="card-image"
src={post.eyecatch.url}
alt=""
width={post.eyecatch.width}
height={post.eyecatch.height}
loading="lazy"
decoding="async"
/>
) : (
<div class="card-image card-noimage" aria-hidden="true" />
)
}
<!-- 公開日・タイトルの部分は省略 -->
</a>
</article>いくつか工夫した点があります。
publishedAtは下書きのコンテンツなどでは入っていないことがあるので、そのときは作成日時(createdAt)を使う- アイキャッチがない記事は、同じ大きさの無地のブロックを出して、カードの高さをそろえる
- 画像には
width・heightを付けて、読み込み中にレイアウトがずれないようにする
日付は、日本時間で「2026/01/31」の形に整えます。
const formatter = new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
/** ISO 8601 の日時を日本時間の「2026/01/31」形式にする */
export function formatDate(iso: string): string {
return formatter.format(new Date(iso));
}microCMS が返す日時は UTC の ISO 8601 形式です。timeZone: 'Asia/Tokyo' を明示しておくと、ビルドするマシンのタイムゾーンが UTC でも、日本時間の日付で表示されます。
カードを並べるコンポーネントでは、記事が0件のときの表示も用意しました。
{
posts.length > 0 ? (
<div class="card-grid">
{posts.map((post) => (
<ArticleCard post={post} />
))}
</div>
) : (
<p>まだ記事がありません。</p>
)
}カードの並びは CSS グリッドで作っています。画面幅に応じて列数が自動で変わるので、メディアクエリは書いていません。
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
gap: 24px;
}min(100%, 300px) にしているのは、画面幅が300pxより狭いときにカードがはみ出さないようにするためです。
4. ページ送り
記事一覧は12件ずつに分けます。URL は、1ページ目が /blog/、2ページ目以降が /blog/page/2/、/blog/page/3/……です。
1ページ目:/blog/
---
import ArticleList from '../../components/ArticleList.astro';
import Pagination from '../../components/Pagination.astro';
import { POSTS_PER_PAGE } from '../../consts';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getBlogSummaries } from '../../lib/microcms';
const posts = await getBlogSummaries();
const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
---
<BaseLayout title="記事一覧">
<h1 class="page-title">記事一覧</h1>
<ArticleList posts={posts.slice(0, POSTS_PER_PAGE)} />
<Pagination currentPage={1} totalPages={totalPages} />
</BaseLayout>2ページ目以降:/blog/page/[page]/
URL の一部が変わるページは、ファイル名を [page].astro のように角かっこで囲み、getStaticPaths で「どのページを作るか」を返します。
export const getStaticPaths = (async () => {
const posts = await getBlogSummaries();
const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
// 1ページ目は /blog/ が担当するので、2ページ目から生成する
return Array.from({ length: Math.max(totalPages - 1, 0) }, (_, i) => {
const page = i + 2;
return {
params: { page: String(page) },
props: {
posts: posts.slice((page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE),
currentPage: page,
totalPages,
},
};
});
}) satisfies GetStaticPaths;ポイントは次の3つです。
paramsが URL の[page]の部分になる。URL に入る値なので文字列にする(String(page))propsはページに渡すデータ。そのページに出す12件と、ページ番号を渡している- 1ページ目は
/blog/が担当するので、2ページ目から作る。記事が12件以下(ページが1つ)のときは、このファイルからは1ページも作られない
1ページ目まで /blog/page/1/ として作ると、/blog/ と同じ内容のページが2つのURLにできてしまいます。それを避けるため、1ページ目だけを別のファイルにしました。
satisfies GetStaticPaths は TypeScript の書き方で、関数が getStaticPaths として正しい形(params と props を持つ配列を返す)になっているかを型チェックしてくれます。
参考:Routing Reference | Astro Docs
ページ送りのリンク
// 1ページ目は /blog/、2ページ目以降は /blog/page/2/ ...
const pageUrl = (page: number) => (page === 1 ? '/blog/' : `/blog/page/${page}/`);「前へ」「次へ」のリンクには rel="prev"・rel="next" を付けています。ページが1つしかないときは、ページ送り自体を出しません。
5. 記事詳細ページ
記事詳細も、[id].astro と getStaticPaths で全記事分を作ります。こちらは本文が必要なので、getAllBlogs を使います。
export const getStaticPaths = (async () => {
const blogs = await getAllBlogs();
return blogs.map((blog) => ({ params: { id: blog.id }, props: { blog } }));
}) satisfies GetStaticPaths;
const { blog } = Astro.props;
const publishedAt = blog.publishedAt ?? blog.createdAt;
const revisedAt = blog.revisedAt ?? publishedAt;
// 同じ日のうちの修正では「更新日」を出さない
const showRevised = formatDate(revisedAt) !== formatDate(publishedAt);記事の URL は /blog/<コンテンツID>/ になります。
公開日と更新日は、microCMS の publishedAt と revisedAt を使います。公開した日のうちに誤字を直しただけで「更新日」が出るのは大げさなので、日付が変わったときだけ更新日を出すようにしました。
本文は、microCMS のリッチエディタが返す HTML を set:html でそのまま流し込みます。
<div class="prose" set:html={blog.content} />set:html に渡した文字列は、Astro がエスケープしません。自分で書いた記事の HTML なので問題ありませんが、利用者が入力した内容などをそのまま渡すと XSS の原因になります。
参考:Template directives reference | Astro Docs
6. トップページに新着記事を出す
トップページは、新着6件と記事一覧へのリンクにしました。
---
import ArticleList from '../components/ArticleList.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getBlogSummaries } from '../lib/microcms';
const LATEST_COUNT = 6;
const posts = (await getBlogSummaries()).slice(0, LATEST_COUNT);
---
<BaseLayout>
<h1 class="page-title">新着記事</h1>
<ArticleList posts={posts} />
<p class="more-link"><a href="/blog/">記事一覧へ →</a></p>
</BaseLayout>このトップページは、のちにサイト全体の紹介ページに作り替えています(トップページのHP化とツール一覧)。
注意点・ハマりどころ
コンテンツIDは URL になる
microCMS のコンテンツIDは、何も指定しないとランダムな英数字になります。記事を作るときに astro-microcms-setup のような意味の分かる英数字とハイフンにしておくと、URL が読みやすくなります。
ただし、page は使わないようにしています。/blog/page/ が一覧のページ送りと紛らわしくなるためです。
fields の綴りを間違えてもエラーにならない
microCMS は、fields に存在しないフィールド名を書いてもエラーを返さず、その項目が返ってこないだけです。型(BlogSummary)では存在することになっていても、実際の値は undefined になります。
フィールドを増やしたときは、SUMMARY_FIELDS への追加も忘れないようにし、実際の API の返却値で確認するのが確実です。
記事を更新したら再ビルドが必要
どのページもビルド時に作っているので、microCMS で記事を公開・更新しても、ビルドし直すまでサイトには反映されません。このサイトでは、microCMS の Webhook から自動で再ビルドするようにしています(microCMS の Webhook で Cloudflare Pages を自動ビルド)。
まとめ
- 共通レイアウトにタイトル・meta description・ヘッダー・フッターをまとめた
- 一覧では
fieldsで本文を省き、型もOmitで本文なしにした - ページ送りは
getStaticPathsで2ページ目以降を作り、1ページ目は/blog/に任せた - 記事詳細は全記事分を
getStaticPathsで生成し、本文はset:htmlで流し込んだ
次回は、本文のコードブロックを shiki でハイライトします。→ 第3回:コードブロックのシンタックスハイライト