連載「Astro × microCMS でブログを作る」第2回

Astro × microCMS で記事一覧・記事詳細・ページ送りを作る|getStaticPaths と fields で静的生成

この回でやること

前回は、Astro のプロジェクトを作り、microCMS から記事を全件取得して、トップページにタイトルを箇条書きで並べるところまで進めました。

今回は、それをブログらしい形にします。作るページは次のとおりです。

URL

内容

/

新着記事(6件)

/blog/

記事一覧(12件ずつ)

/blog/page/<n>/

記事一覧の2ページ目以降

/blog/<id>/

記事詳細

どのページもビルド時に 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.css

1. 共通レイアウトを作る

まず、すべてのページで使うレイアウトを作ります。<head> の中身・ヘッダー・フッターをここにまとめ、ページごとに変わる部分は <slot /> に入れます。

src/layouts/BaseLayout.astro
---
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ページの件数は、あとで変えやすいように定数にしました。

src/consts.ts
export const SITE_NAME = 'cyber-blow';
export const SITE_DESCRIPTION =
	'エラー解決・検証・比較・AIツール活用術など、実際に手を動かして確かめたIT技術情報を発信するブログ。';

/** 記事一覧1ページあたりの表示件数 */
export const POSTS_PER_PAGE = 12;

2. 一覧では本文を取得しない

前回作った getAllBlogs は、本文(content)も含めて全記事を取得します。記事詳細を作るには本文が必要ですが、一覧に並べるのはタイトルや日付、アイキャッチだけです。

そこで、一覧用に本文を除いて取得する関数を追加しました。

src/lib/microcms.ts
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 を作り、一覧のコンポーネントでうっかり本文を使えないようにしています。

参考:一覧取得API | microCMS ドキュメント

3. 記事カードと一覧のコンポーネント

一覧の1件分を表示する記事カードです。アイキャッチ・カテゴリ・公開日・タイトルを出し、カード全体を記事へのリンクにしています。

src/components/ArticleCard.astro
---
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」の形に整えます。

src/lib/date.ts
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件のときの表示も用意しました。

src/components/ArticleList.astro
{
	posts.length > 0 ? (
		<div class="card-grid">
			{posts.map((post) => (
				<ArticleCard post={post} />
			))}
		</div>
	) : (
		<p>まだ記事がありません。</p>
	)
}

カードの並びは CSS グリッドで作っています。画面幅に応じて列数が自動で変わるので、メディアクエリは書いていません。

src/styles/global.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/

src/pages/blog/index.astro
---
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 で「どのページを作るか」を返します。

src/pages/blog/page/[page].astro
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

ページ送りのリンク

src/components/Pagination.astro
// 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 を使います。

src/pages/blog/[id].astro
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 でそのまま流し込みます。

src/pages/blog/[id].astro
<div class="prose" set:html={blog.content} />

set:html に渡した文字列は、Astro がエスケープしません。自分で書いた記事の HTML なので問題ありませんが、利用者が入力した内容などをそのまま渡すと XSS の原因になります。

参考:Template directives reference | Astro Docs

6. トップページに新着記事を出す

トップページは、新着6件と記事一覧へのリンクにしました。

src/pages/index.astro
---
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回:コードブロックのシンタックスハイライト