はじめに

今回の記事では、これまで Gatsby.js(v5)で運用していたこのブログを、Astro にリプレースした際の流れと、途中で詰まった箇所をまとめていこうと思います。

移行を考え始めたきっかけは、ビルド時間と依存パッケージの重さでした。記事数が増えるにつれて gatsby build の終わりが遠くなり、ローカルで一度ビルドを通すのも腰が重くなってきていたのが正直なところです。それと、Gatsby v5 系以降の更新ペースや周辺プラグインのメンテナンス状況を見ているうちに、自分のサイト規模に対しては GraphQL ベースのデータレイヤーまで抱える必要は無いのでは、という気持ちが強くなり、Astro へ移すことにしました。

結論から書くと、移してよかったとは思っています。ビルドの待ち時間と node_modules のサイズはそれぞれ目に見えて減りました。ただし、Gatsby のプラグインで何となく動いていた部分を Astro 側で自前で書き直す羽目になり、想像していたよりも手数がかかったというのも正直なところです。今回はその辺りの話を中心に書いていきます。

移行前後のスタック対比

まずは移行前と移行後のスタックを大まかに並べておきます。

役割旧(Gatsby v5)新(Astro v6)
データ取得GraphQL + allMarkdownRemarkContent Collections (getCollection)
ページ生成gatsby-node.jscreatePagegetStaticPaths
画像最適化gatsby-plugin-image / gatsby-remark-imagesastro:assets + sharp
シンタックスハイライトgatsby-remark-prismjs + -titleAstro 標準 (syntaxHighlight: 'prism')
RSSgatsby-plugin-feed@astrojs/rss
サイトマップgatsby-plugin-sitemap@astrojs/sitemap
UI レイヤReact 全面Astro コンポーネント中心 + 必要箇所のみ React のアイランド
スタイルSCSS + Tailwind v3SCSS + Tailwind v4 (@tailwindcss/vite)

ディレクトリ構成も少し変わりました。Gatsby 時代は src/templates/ 配下にテンプレートを置き、gatsby-node.js から createPage で生やしていましたが、Astro では src/pages/[slug]/index.astro を一枚置けば、getStaticPaths の戻り値で全ての記事ページを生やしてくれます。gatsby-node.js が 400 行近くまで膨らんでいたのを思い出すと、ここはかなりすっきりしました。

UI レイヤは React 一択から、Astro コンポーネントを基本に据えて、フォームなどクライアントサイドの状態が必要な箇所だけ React のアイランドとして残す形に変えています。Astro 公式が Islands Architecture(アイランドアーキテクチャ)と呼んでいる構成で、ページ全体は静的 HTML のまま、インタラクティブな部分だけクライアントで hydrate される、という割り切り方になります。初期 JS バンドルがそれなりに削減できたのは、この変更の効果が大きかったところです。

frontmatter スキーマと記事ファイルの再利用

移行作業で一番気になっていたのが、過去記事の Markdown を書き換える羽目になるのかどうかでした。結論から書くと、ここはほぼノータッチで済みました。

Astro 側では Content Collections のスキーマを Zod で定義します。src/content.config.ts で次のように書いています。

src/content.config.ts
const blog = defineCollection({
  loader: glob({ pattern: '**/index.md', base: './src/content/blog', generateId: stripIndex }),
  schema: z.object({
    title: z.string(),
    date: z.string(),
    description: z.coerce.string().default(''),
    pagetype: z.coerce.string().default('blog'),
    hero: z.coerce.string().optional(),
    cate: z.coerce.string().default(''),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
    modifieddate: z.coerce.string().optional(),
  }),
});

Gatsby 時代から使っていた title / date / description / pagetype / hero / cate / tags / draft がそのまま素直にマッピングできたので、frontmatter 側の修正はほぼ発生していません。z.coerce.string() を入れているのは、過去記事で文字列でも数値でも入りうる箇所があったための保険です。

唯一ちょこちょこ手を入れたのは draft で、過去に文字列として draft: "true" と書いていた記事がいくつかあり、z.boolean() を通そうとして弾かれました。ここは grep で拾って boolean に書き直すだけだったので、特に難所ではありません。

Markdown 本文側の相対画像パス(./hero.png のような書き方)も、Astro の Markdown 統合がそのまま解釈してくれたので、本文の書き換えは発生しませんでした。

コードブロックのファイル名表示と自作 remark プラグイン

ここからが、軽く見ていて時間を溶かした方の話です。

このブログでは、コードブロックの上にファイル名を表示するために、Gatsby 時代から gatsby-remark-prismjs-title を使っていました。記法はこんな感じです。

```csharp:title="App.xaml.cs"
// コード
```

「title 付きで書ける Prism のプラグイン」自体は割とよくある構成だったので、Astro に移ったら同じノリで対応している remark プラグインを入れて終わりだろう、と楽観していました。実際には、自分が探した範囲では :title="..." 記法をそのまま吸ってくれる remark プラグインが見つからず、結局自作することになりました。

書いてみると処理自体は単純で、code ノードを訪問して node.lang の中の :title= 部分を抜き出し、別のノードに分けるだけです。最終的には次のようなプラグインに落ち着きました。

src/plugins/remark-code-titles.mjs
import { visit } from 'unist-util-visit';

export default function remarkCodeTitles() {
  return (tree) => {
    visit(tree, 'code', (node, index, parent) => {
      if (!node.lang || index === undefined || !parent) return;

      const match = node.lang.match(/^([\w.+-]+):title=["']?(.+?)["']?$/);
      if (!match) return;

      const [, cleanLang, title] = match;
      node.lang = cleanLang;

      const titleNode = {
        type: 'html',
        value: `<div class="code-title">${escapeHtml(title)}</div>`,
      };

      parent.children.splice(index, 0, titleNode);
      return index + 2;
    });
  };
}

書いた後で読み返すと当たり前のコードなのですが、最初は正規表現の取りこぼしや、html ノードを差し込む位置で遊ばれて半日ほど溶かしました。Gatsby 時代に「プラグインを gatsby-config.js に並べるだけ」で済んでいた箇所が、こうして一段階手前まで降りてくる、というのが Astro 移行で何度か出てくるパターンでした。

Markdown 内のカスタムタグ(card / affiliatecard)の再現

このブログでは、関連記事を埋め込むために Markdown 内に <card slug="/2020-03-15/"></card> のような独自タグを書く運用をしていました。アフィリエイトリンク用に <affiliatecard ... /> も同様です。Gatsby 時代は gatsby-remark-component で React コンポーネントへ自動マウントされていたので、書き手としては HTML タグのつもりで埋めるだけで成立していました。

これを Astro でどう再現するかが、移行で一番時間がかかった箇所です。

Astro の Markdown は、生 HTML を本文中に書くと標準では一部しか拾われません。rehype-raw を入れて素のまま通そうとすると、今度は MDX 由来のノード型(mdxJsxFlowElement など)が後段の処理で剥ぎ取られて消えてしまうという現象に遭遇し、最終的に astro.config.mjs 側で passThrough を明示する形に落ち着きました。

astro.config.mjs
markdown: {
  syntaxHighlight: 'prism',
  remarkPlugins: [remarkCodeTitles, remarkSmartypants],
  rehypePlugins: [
    [rehypeRaw, { passThrough: ['mdxFlowExpression', 'mdxJsxFlowElement', 'mdxJsxTextElement', 'mdxTextExpression', 'mdxjsEsm'] }],
    rehypeSlug,
    [rehypeAutolinkHeadings, { behavior: 'wrap' }],
    [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
    rehypeCustomCards,
  ],
},

ここまで来てから、ようやく rehypeCustomCards を自作して、<card slug="..." /> を見つけたら slug をキーに記事 frontmatter を引き、リンクカードの HTML を組み立てる、という処理に書き直しました。slug → メタ情報の引き当ては毎回 IO すると重くなるので、src/plugins/rehype-custom-cards.mjs の中で一度だけ frontmatter を全件ロードしてマップに保持しています。

src/plugins/rehype-custom-cards.mjs
let articleMap = null;
function loadArticleMap() {
  if (articleMap) return articleMap;
  articleMap = new Map();
  const roots = ['src/content/blog', 'src/content/product'];
  for (const root of roots) {
    // ...frontmatter を読み込んで Map に格納...
  }
  return articleMap;
}

ここまで来るのに、rehype-raw のドキュメントと unified の AST 仕様を行ったり来たりして丸一日溶かしました。Gatsby 時代は本当に「タグを書くだけで動いていた」感があったので、この差は地味に堪えます。

画像最適化の置き換え

Gatsby の画像周りはとにかく至れり尽くせりで、gatsby-plugin-imagegatsby-remark-images を入れておけば、Markdown に画像を貼るだけで遅延ロード・複数解像度・blur プレースホルダーまで全自動でついてきていました。ここを置き換えるのは、覚悟していた以上に手数が必要でした。

Astro には astro:assets がありますが、これは基本的に .astro 側で import した画像に対する仕組みで、Markdown 本文中の相対パス画像については別経路で処理されます。最終的には Astro 標準の Markdown 画像処理+ sharp で揃えていますが、その過程で大きめのスクリーンショットが sharplimitInputPixels ガードに引っかかり、ビルドが落ちるケースに当たりました。

astro.config.mjs で次のように外してあるのは、その対処です。

astro.config.mjs
image: {
  service: {
    entrypoint: 'astro/assets/services/sharp',
    config: { limitInputPixels: false },
  },
},

クリックで拡大表示する挙動については、Gatsby 時代に使っていた gatsby-remark-images-medium-zoom が無いので、medium-zoom を直接呼ぶ React のアイランドとして組み込み直しました。「Markdown を書くだけで何でもやってくれる世界」から、「自分で線を引き直す世界」へ降りた感覚がいちばん強かったのが、この画像周りでした。

シンタックスハイライトとファイル名タイトル

シンタックスハイライト自体は、Astro が標準で Prism をサポートしていたので、syntaxHighlight: 'prism' を指定し、Gatsby 時代に使っていた prism.css をほぼそのまま src/prism.css に持ち込むだけで色味は揃いました。

astro.config.mjs
markdown: {
  syntaxHighlight: 'prism',
  // ...
}

ファイル名タイトル付きのコードブロックについては、前述の自作 remark プラグインに任せています。CSS 側の .code-title クラスを少し整えれば、Gatsby 時代と区別がつかない見た目に揃えることができました。

関連記事・前後ナビ・人気記事の再実装

Gatsby 時代は gatsby-node.jscreatePagecontext に、前後の記事 ID をビルド時に詰めて渡していました。Astro ではページ単位で見ると、各記事ページが自分で全件取得してから前後を計算する形になります。具体的には src/components/astro/PostNav.astrogetCollection を呼んでソートし、現在のスラッグと比較して前後を決める、という素朴な書き方です。記事数が今のところ数百本なので、ビルド時に毎回全件舐めても気にならない速度に収まっていますが、ここは将来的にキャッシュを入れたい箇所として頭の隅に置いてあります。

関連記事は src/components/astro/RelatedPosts.astro、人気記事は src/components/astro/PopularPosts.astro で、いずれも Astro コンポーネントとして実装しています。人気記事の取得は GA4 Data API をビルド時に叩く方式(Gatsby 時代と同じ思想)で、src/lib/ga4.ts にまとめてあります。サーバ側の処理を Astro コンポーネントの frontmatter 領域でそのまま書ける(fetch まで含めて await できる)のは、Gatsby + GraphQL でデータレイヤーを通していた時代と比べると、書き味が素直で気持ちが良いところでした。

RSS とサイトマップ、検索インデックス

RSS は @astrojs/rss を使い、src/pages/rss.xml.jsgetCollection を呼んで XML を組み立てています。サイトマップは @astrojs/sitemapastro.config.mjs の integrations に追加するだけで、/sitemap-index.xml まで自動で吐いてくれます。記事一覧から外したいパスは filter オプションで除外できるので、Gatsby 時代に GraphQL の serialize で書いていたものより、コードとしては素直になりました。

検索は外部サービスを使わず、ビルド時に search-index.json を生成してクライアント側で部分一致を取る方式です。src/pages/search-index.json.js でドラフト除外を入れた上で全記事のメタ情報を JSON にまとめています。Gatsby 時代の構成をほぼ踏襲しているので、ここはあまり詰まる箇所はありませんでした。

下書きフラグの扱い

Gatsby 時代は allMarkdownRemark の filter で draft: { ne: true } を書き、開発時は別クエリで全件取得する、という運用をしていました。Astro でも同じことができるかが移行前の懸念だったのですが、ここはかなりシンプルに片付きます。

src/content.config.ts のスキーマで draft: z.boolean().default(false) を持っているので、getCollection のフィルタ関数で次のように書けば良いだけです。

src/pages/index.astro
const all = await getCollection('blog', ({ data }) =>
  import.meta.env.PROD ? data.draft !== true : true
);

import.meta.env.PROD が本番ビルド時に true になることを利用し、本番では draft: true の記事を除外、開発サーバでは表示する、という動きにしています。記事一覧、タグ、カテゴリ、[slug]/index.astrogetStaticPaths、RSS、サイトマップ、検索インデックスのすべてに同じイディオムを入れているので、frontmatter で draft: true と書いてある記事は本番ビルド側のあらゆる経路から消えてくれます。

書いている記事を共有レビューに回すまでの間は dev サーバ側でだけ確認できる、という運用が Gatsby 時代と同じ感覚で続けられたので、ここは安心できた数少ない箇所でした。

Tailwind v4 への移行

Astro へ移すついでに、Tailwind を v3 から v4(@tailwindcss/vite)へ上げることにしました。これは半分くらい欲をかいた判断で、後半に少し痛い目を見ました。

Tailwind v4 は CSS 変数まわりの体系がだいぶ変わっており、既存の src/scss/ 配下に積み上がっていた SCSS 変数やミックスインと、すぐには素直に噛み合わない箇所が出ます。最初は SCSS を全部書き直す方向で手を動かしましたが、CSS 変数化したい部分とそうでない部分のラインを引き直す作業が思ったよりも多く、途中でスコープが膨らみすぎている感覚に陥りました。

最終的には、影響が大きい共通レイアウト部分だけを Tailwind v4 に寄せ、記事本文や細かい UI に紐づく SCSS は当面そのまま残す、という段階移行で落ち着いています。「移行のついでに上げる」が常に良い判断とは限らない、というのを久しぶりに学び直した格好になりました。

移行してみての所感

ここまで書いておいて何ですが、Astro 自体の書き味は気に入っています。Astro コンポーネントの frontmatter 領域で getCollectionfetch まで含めたサーバ処理がそのまま書け、必要な箇所だけ React のアイランドを置く、という割り切りは個人ブログのように静的寄りなサイトとは相性が良く、いざ書いてみると Gatsby + GraphQL の頃よりも記述量が減ることのほうが多かったです。

一方で、Gatsby が「プラグインを並べるだけで一通りの機能が揃う」フレームワークだったことを、移行を通じて改めて意識しました。コードブロックのファイル名表示、Markdown 内のカスタムタグ、画像最適化、サイトマップや RSS のような周辺機能まで、Gatsby 時代は確かに楽をさせてもらっていた部分が多くあります。Astro でこれを再現しようとすると、remark / rehype のエコシステムを少し学び、必要であれば自分で AST を触るプラグインを書く、という覚悟が要るところが正直なところです。

裏返すと、自分の手で書いた範囲しか動いていないので、ビルドが落ちたときに原因を追いやすいのは確実な利点でした。Gatsby 時代に GraphQL とプラグインの間で「どっちが悪いのか分からない」状態になっていたデバッグの体感が、Astro では明らかに減っています。

ビルド時間と初期 JS バンドルは体感ではっきり軽くなりました。具体的な数値は環境差が大きいので書きませんが、npm run build の終わりを待ちながら他の作業を始める、というのが無くなった、というのが個人的にはいちばん大きな効果でした。

まとめ

最後にポイントだけ短く並べておきます。

  • 既存記事の frontmatter は Zod スキーマで素直に拾えるので、本文の書き換えはほぼ不要だった
  • 一方で、Gatsby のプラグイン任せになっていた箇所(コードタイトル、カスタムタグ、画像、ハイライト)は remark / rehype で自作する覚悟がいる
  • Markdown 内のカスタムタグは rehype-rawpassThrough 設定で詰まりやすいので、AST を通る経路を落ち着いて追うこと
  • 画像最適化は Astro 標準+ sharp で揃うが、limitInputPixels などのガードに当たることがある
  • 下書きフラグは import.meta.env.PROD を組み合わせれば Gatsby 時代と同じ感覚で運用できる
  • 「移行のついでに依存も全部上げる」は無理に頑張らず、段階移行に逃げる方が結果的に早いことが多い

参考記事