Thinking in the Grey

AstroとmicroCMSでブログを作成してみた

はじめに

AstroとmicroCMSでブログを作成しました。(※このブログのことです。)

記事ではブログを作成する流れを解説していきます。

技術スタックと機能は以下のとおりです。

技術スタック

  • CMS:microCMS
  • フレームワーク:Astro
  • デプロイ先:Cloudflare

機能

  • タグ一覧
  • 目次
  • シンタックスハイライト
  • ダークモード

構成

microCMSのデータ構成

blogs:titleに記事タイトル、bodyにリッチエディタの本文、tagsにタグへの複数コンテンツ参照を持たせます。

blogs.json
{
  "contents": [
    {
      "id": "ekgrhcgdn",
      "createdAt": "2026-07-30T07:04:11.041Z",
      "updatedAt": "2026-07-30T07:04:11.041Z",
      "publishedAt": "2026-07-30T07:04:11.041Z",
      "revisedAt": "2026-07-30T07:04:11.041Z",
      "title": "Astro × microCMS でブログを作る",
      "body": "<p>Astroの静的サイト生成と、microCMSのようなヘッドレスCMSを組み合わ~~",
      "tags": [
        {
          "id": "uy6bm2xa6",
          "createdAt": "2024-11-14T00:48:21.273Z",
          "updatedAt": "2025-08-29T08:02:59.725Z",
          "publishedAt": "2024-11-14T00:48:21.273Z",
          "revisedAt": "2025-08-29T08:02:59.725Z",
          "title": "JavaScript",
          "slug": "javascript"
        }
      ]
    }
  ],
  "totalCount": 1,
  "offset": 0,
  "limit": 10
}

tags:titleに表示名、slugにURLで使う文字列を持たせます。

tags.json
{
    "contents": [
        {
            "id": "hj5k20l8f",
            "createdAt": "2024-11-14T00:47:32.908Z",
            "updatedAt": "2025-03-25T06:24:11.761Z",
            "publishedAt": "2024-11-14T00:47:32.908Z",
            "revisedAt": "2025-03-25T06:24:11.761Z",
            "title": "Accessibility",
            "slug": "a11y"
        }
    ],
    "totalCount": 1,
    "offset": 0,
    "limit": 10
}

Astroのファイル構成

src
src/
├── content.config.ts           # microCMSの取得処理とスキーマ
├── components/
│   ├── BaseHead.astro          # メタ情報、共通CSS、表示設定の初期化
│   ├── ArticlesList.astro      # 記事一覧
│   ├── TagsList.astro          # タグナビゲーション
│   ├── TableOfContents.astro   # 目次
│   └── ThemeToggle.astro       # 配色の切替
├── layouts/
│   └── BlogPost.astro          # 記事の共通レイアウト
├── lib/
│   └── microcms.ts             # CMS本文のHTML加工
├── pages/
│   ├── index.astro             # トップページ
│   ├── about.astro             # ブログ・著者の紹介
│   ├── blog/[...slug].astro    # 記事詳細
│   ├── tags/[slug].astro       # タグ別記事一覧
│   └── rss.xml.js              # RSS
├── scripts/                    # 目次メニューや画像拡大の操作
└── styles/global.css           # 共通スタイルとテーマ変数

ページの構成

ページの役割は次のように分かれています。

  • /:公開日時の新しい順に記事を並べ、使われているタグを表示
  • /tags/{slug}/:指定したタグを持つ記事を表示
  • /blog/{id}/:記事本文、公開日、タグ、目次、関連記事を表示
  • /about/:リポジトリ内に記述したブログと著者の紹介を表示
  • /rss.xml:記事情報からRSSを生成

トップページとタグページは、どちらもTagsListとArticlesListを使います。

記事詳細はBlogPostレイアウトに本文を渡し、関連記事の表示にもArticlesListを再利用しています。

実装

記事一覧の取得

microCMSへのアクセスをまとめているのが、src/content.config.tsのmicroCMSLoaderです。

src/content.config.ts
const microCMSLoader = (endpoint: string) => {
  return async () => {
    const contents = await client.getAllContents({ endpoint });
    return contents;
  };
};

エンドポイント名を受け取り、その全件取得を行う非同期関数を返します。

実際の実装には取得開始・件数のログと、失敗したエンドポイントが分かるエラー処理も加えています。

このloaderを、データの形を表すschemaと一緒にコレクションへ登録します。

src/content.config.ts
const blogs = defineCollection({
  loader: microCMSLoader(&apos;blogs&apos;),
  schema: z.object({
    title: z.string(),
    body: z.string(),
    tags: z.array(tagSchema).optional().default([]),
    ...microCMSDateFields,
  }),
});

const tags = defineCollection({
  loader: microCMSLoader(&apos;tags&apos;),
  schema: tagSchema,
});

export const collections = { blogs, tags };

loaderが取り込んだデータは、AstroのContentCollectionsを通して利用します。

schemaを定義することで、データの検証とページ側で使う型を揃えられます。

仕組みの詳細はAstroのContentCollectionsガイドで確認できます。

トップページではgetCollection('blogs')で記事を取り出し、公開日時の降順に並べます。

src/pages/index.astro
const posts = (await getCollection(&apos;blogs&apos;)).sort(
  (a, b) =>
    new Date(b.data.publishedAt).valueOf() -
    new Date(a.data.publishedAt).valueOf(),
);

const usedTagSlugs = new Set(
  posts.flatMap((post) => post.data.tags.map((tag) => tag.slug)),
);

const tags = (await getCollection(&apos;tags&apos;))
  .filter((tag) => usedTagSlugs.has(tag.data.slug))
  .sort((a, b) => a.data.title.localeCompare(b.data.title, &apos;ja&apos;));

タグ一覧は、記事で使っているslugをSetに集めてから絞り込んでいます。

これにより、記事に使っていないタグはナビゲーションに出ません。

残ったタグは、日本語の表示名で並べ替えます。

取得した記事はArticlesListに渡します。

一覧のリンクは/blog/${post.id}/で、タイトル・公開年・タグが一つの行にまとまっています。

現在の実装にはページネーションはなく、取得した記事を全件表示します。

ここでのgetCollection()は、ページから取り込み済みのコレクションを読む処理です。

タグ別記事一覧の取得

タグページはsrc/pages/tags/[slug].astroで生成します。

静的サイトで動的ルートを使う場合、getStaticPaths()で生成対象のURLを列挙します。

この仕組みはAstroのルーティングガイドに説明されています。

このリポジトリでは、トップページと同じく使用中のタグだけを対象にしています。

allTagsにそのタグ一覧、postsに全記事が入った状態で、次のように生成対象を返します。

src/pages/tags/[slug].astro
return allTags.map((tag) => ({
  params: { slug: tag.data.slug },
  props: {
    tag,
    tags: allTags,
    posts: posts
      .filter((post) =>
        post.data.tags.some((t) => t.slug === tag.data.slug),
      )
      .sort(
        (a, b) =>
          new Date(b.data.publishedAt).valueOf() -
          new Date(a.data.publishedAt).valueOf(),
      ),
  },
}));

params.slugがURLの[slug]に対応し、propsが各ページの表示データになります。

記事のタグ配列に同じslugが一つでもあれば、そのタグの記事として採用します。

タグの判定や並べ替えはビルド時に行われます。

TagsListにはcurrentSlugも渡し、現在のタグにcurrentクラスとaria-current="page"を付けます。

直接URLを開いた時点で、選択状態を含んだHTMLが表示される構成です。

記事本文の取得

記事詳細のsrc/pages/blog/[...slug].astroも、getStaticPaths()で全記事のページを生成します。

src/pages/blog/[...slug].astro
export async function getStaticPaths() {
  const posts = await getCollection(&apos;blogs&apos;);
  return posts.map((post) => ({
    params: { slug: post.id },
    props: post,
  }));
}

type Props = CollectionEntry<&apos;blogs&apos;>;
const post = Astro.props;

ファイル名は[...slug].astroですが、渡している値はpost.idです。

本文のbodyはHTML文字列です。 表示する前に、src/lib/microcms.tsの関数で加工しています。

src/pages/blog/[...slug].astro
const { html: htmlWithIds, headings } = withHeadingIds(post.data.body);
const html = withOrderedListIds(await highlightCodeBlocks(htmlWithIds));
const description = plainTextExcerpt(post.data.body);

withHeadingIds()は本文中のh2とh3を抽出し、見出しの階層・ID・テキストを目次用データにまとめます。

既存のIDは残し、IDのない見出しにはheading-1のような連番を付けます。

続いてコードブロックをShikiで変換し、withOrderedListIds()で順序付きリストの項目にref-1のようなIDを付けます。

リスト項目も、ページ内リンクの参照先として使えるようになります。

plainTextExcerpt()は元の本文からHTMLタグを取り除き、空白をまとめて、標準では120文字までの説明文を作ります。

この説明文は記事のメタ情報に使い、RSSでも同じ関数を利用しています。

加工後のHTMLは、BlogPostのスロットに渡します。

src/pages/blog/[...slug].astro
<div set:html={html} />

set:htmlに渡すことで、本文をHTMLとして描画します。

この処理にはサニタイズは含まれておらず、現在の実装は管理されたCMSの本文を出力する前提です。

BlogPost.astroは本文に加えて、タイトル、公開日、タグ、目次、スクロール中に使うContentsメニューを配置します。

本文の基本スタイルにはgithub-markdown-cssを利用し、見出しやリスト、コードブロックの見た目を追加のCSSで調整しています。

記事末尾の関連記事は、現在の記事と共通タグを持つものを新しい順に最大5件選びます。

候補が2件以上なら「RelatedArticles」として表示し、それ未満なら、現在の記事を除く最新記事を最大5件「LatestArticles」として表示します。

シンタックスハイライトを導入

microCMSの本文はHTMLなので、AstroのMarkdown処理を通りません。

そのため、astro.config.mjsにMarkdown用のShiki設定を書くだけでは、CMS本文中のコードには適用されません。

そこでhighlightCodeBlocks()が本文中の<pre><code>...</code></pre>を探し、ShikiのcodeToHtml()に渡しています。

src/lib/microcms.ts
import { bundledLanguages, codeToHtml } from &apos;shiki&apos;;
import astroConfig from &apos;../../astro.config.mjs&apos;;

const shikiThemes = astroConfig.markdown!.shikiConfig!.themes!;

テーマ設定はAstroの設定ファイルから読み込みます。

Markdown用とCMS本文用で同じ設定を参照するため、変更箇所を一つにまとめられます。

現在は両方の配色モードにgithub-dark-high-contrastを指定しています。

astro.config.mjs
markdown: {
  shikiConfig: {
    themes: {
      light: &apos;github-dark-high-contrast&apos;,
      dark: &apos;github-dark-high-contrast&apos;,
    },
  },
},

変換時はpreまたはcodeの属性にあるlanguage-xxxから言語を判定します。

bundledLanguagesにない値や言語指定のないコードはplaintextとし、ハイライト処理が失敗した場合もplaintextで再処理します。

コード内の&lt;や&gt;などは、Shikiに渡す前にデコードします。

ダークモードを導入

配色の初期化は、全ページで使うBaseHead.astroにまとめています。

保存済みの設定を優先し、保存値がなければOSの設定を参照します。

src/components/BaseHead.astro
function resolve() {
  const stored = localStorage.getItem(&apos;color-scheme&apos;);
  if (stored === &apos;light&apos; || stored === &apos;dark&apos;) return stored;
  return matchMedia(&apos;(prefers-color-scheme: dark)&apos;).matches
    ? &apos;dark&apos;
    : &apos;light&apos;;
}

決定した値は、htmlのdata-themeとstyle.colorScheme、color-schemeのメタ情報へ反映します。

head内のインラインスクリプトで早い段階に適用し、初期表示の配色が切り替わるちらつきを抑える狙いです。

共通CSSは、配色を変数として定義しています。

src/styles/global.css
--color-ink: light-dark(#0f1219, #e6edf3);
--color-page: light-dark(#ffffff, #0d1117);
--color-border: light-dark(#000000, #e6edf3);

背景・文字・罫線をこれらの変数で指定することで、同じレイアウトのまま配色を切り替えます。

本文に使うgithub-markdown-cssには、ライト用とダーク用のCSSをそれぞれ用意しています。

設定を適用する関数は、対応するlink要素のmediaとdisabledも更新します。

サイト全体だけでなく、記事本文の配色も手動選択に合わせるためです。

ThemeToggle.astroは、クリック時に選択をlocalStorageへ保存し、ボタンのアイコン・説明・ツールチップを更新します。

手動設定がない場合は、OS側の配色変更にも追従します。

デプロイ

CloudflarePagesへの公開

ここからは、このリポジトリをCloudflarePagesへ公開する場合の設定です。

リポジトリ内にはCloudflareのプロジェクト設定やWebhookの登録情報がないため、以下は導入手順として説明します。

現在の構成は静的ページを生成するもので、Cloudflare用のサーバーアダプターは導入していません。

ビルド成果物のdistを公開する方針です。

CloudflarePagesでGitリポジトリを接続し、ビルドコマンドをnpmrunbuild、出力ディレクトリをdistに設定します。

Git連携では、対象ブランチへのpushによる再ビルドも利用できます。

CloudflareのAstro向けガイドに設定例があります。

このリポジトリでは、ビルド環境に次の準備も必要です。

siteはcanonicalURL、RSS、サイトマップなどに使われます。

公開前には、一覧・記事詳細・タグページに加え、RSSやサイトマップのURLも確認します。

Webhookで記事更新を反映する

静的ページに含まれる記事は、ビルド時点の内容です。

CloudflarePagesの対象プロジェクトで、Settings>Builds>Adddeployhookからフック名とビルド対象ブランチを設定し、URLを発行します。

このURLにHTTPPOSTを送るとデプロイが始まります。

CloudflareのDeployHooksドキュメントに手順が掲載されています。

次に、microCMSの各APIの「API設定→Webhook」から「CloudflarePages」を選び、発行したURLを登録します。

公開・更新だけでなく、公開終了や削除など、サイトに反映したい変更に合わせて通知タイミングを選びます。

設定できるイベントはmicroCMSのWebhookドキュメントで確認できます。

おわりに

無事AstroとmicroCMSを使用してブログを作成することができました。

さらに検索機能を追加したり、swupなどのインテグレーションを試すことを予定しています。

AstroとmicroCMSでブログを作成してみた

Latest Articles