← tech

48本の記事を壊さず、Next.jsからAstroへブログを移行した

公開日: / 更新日:

Next.jsで運用してきた技術ブログをAstroへ移行した。既存のMarkdown、公開済みURL、検索、RSS、投票APIをどう残したかを、実際のコードと検証方法から振り返る。

目次

このブログは、もともとNext.jsとMDXで作っていました。 当時の構成は、以前書いたNext.jsとMDXでテックブログを一から構築するに残しています。

記事が増えるにつれて、タグ一覧、検索、RSS、OG画像、投票機能も足していきました。 移行を始めた時点で記事は48本あり、URLもすでに外部から参照されています。 新しく作るより、今あるものを壊さずに載せ替えるほうが難しい状態でした。

それでもAstroへ移したのは、このブログでReactを使う範囲が、サイト全体である必要はないと感じたからです。 記事本文と一覧は公開時に内容が決まります。 閲覧時の処理が必要なのは、投票やテーマ切り替えなど一部だけです。

Astroは、この「ほとんど静的で、一部だけ動的」という分け方をそのままコードにできます。

Astroへ移行したTorqueNap Blogの記事ページ。本文、タグ、折りたたみ式の目次を表示している

Astroへ移行した記事ページ。画面幅が狭い場合は、見出しから作った目次を折りたたんで表示する。

先に移行の完了条件を決めた

最初にAstroのコンポーネントを書き始めたわけではありません。 先に、何ができれば移行完了とするかを決めました。

  • content/<年>/にあるMarkdownとMDXをそのまま使える
  • /blog/<slug>/<category>/<slug>の両方で過去記事を表示できる
  • タグ、検索、RSS、サイトマップから記事が欠けない
  • Mermaid、脚注、ファイル名付きコードブロックが崩れない
  • OG画像と投票APIがVercel上で動く
  • 下書きが記事一覧や検索結果へ出ない

見た目を再現できても、記事のURLが変われば過去のリンクは切れます。 RSSに記事が出なければ、ブラウザでページを眺めただけでは気付けません。 そのため、画面の再現とコンテンツの互換性を別の課題として扱いました。

Next.jsから変えたかったこと

Next.jsで困っていた機能があったわけではありません。 静的生成もAPI Routesも使えており、ブログとして動いていました。

変えたかったのは、実装を考えるときの出発点です。 Next.js版では、記事ページやサイドバーも含めてReactコンポーネントとして組み立てていました。 Astroでは、何も指定しなければAstroコンポーネントはHTMLとして出力され、ブラウザへコンポーネントの実行コードを送りません。 JavaScriptを使う場所を、後から明示的に足していく考え方になります。

これは「Astroなら必ず高速になる」という話ではありません。 大きなスクリプトを読み込ませれば、Astroでもページは重くなります。 今回は移行前後で同じ条件の性能測定をしていないため、表示速度の改善は成果として挙げません。 コード上で静的処理と動的処理の境界が見えるようになったことを評価しています。

Astroの標準的なレンダリングとアイランドの考え方は、公式ドキュメントの次のページで確認できます。

静的生成と動的処理を分ける

移行後は、リポジトリ直下のcontentpublicを残し、Astroアプリをapps/tech-blogへ置きました。 過去記事と画像の保存場所を変えず、表示側だけを置き換える構成です。

torque_nap_blog/
├── apps/
│   └── tech-blog/
│       ├── src/
│       │   ├── components/
│       │   ├── layouts/
│       │   ├── lib/
│       │   └── pages/
│       └── astro.config.mjs
├── content/
│   └── <年>/
├── public/
└── package.json

astro.config.mjsでは静的生成を標準にし、リポジトリ直下のpublicを参照しています。 Vercelアダプターは、投票とOG画像のAPIをリクエスト時に動かすために必要です。

import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import vercel from "@astrojs/vercel";

export default defineConfig({
  output: "static",
  adapter: vercel(),
  publicDir: "../../public",
  trailingSlash: "never",
  integrations: [mdx()],
});

処理の流れは次のようになります。

flowchart TB
    subgraph Build["ビルド時"]
        Content["Markdown / MDX"] --> Collection["Content Collections<br>読み込みと検証"]
        Collection --> Routes["getStaticPaths<br>記事と一覧のURLを作る"]
        Routes --> HTML["静的HTML<br>記事、タグ、検索、RSS"]
    end

    HTML --> Browser["読者のブラウザ"]

    subgraph Runtime["リクエスト時"]
        Browser --> Feedback["投票API"]
        Feedback --> KV["Vercel KV"]
        Browser --> OG["OG画像API"]
    end

記事の配信はVercel KVに依存しません。 投票APIが停止しても、生成済みの記事は表示できます。

過去記事をContent Collectionsへ載せる

Astroでは、記事をContent Collectionsとして読み込みます。 glob()ローダーには、Astroアプリの外にあるcontentを指定しました。

Astro公式ドキュメントのContent Collectionsページ

Astro公式ドキュメントのContent Collectionsページ。画面は2026年9月7日に取得した。UIは日本語だが、このページの本文はまだ日本語化されていなかった。

このブログのフロントマターは、全記事で完全には揃っていません。 概要がdescriptionの記事とexcerptの記事があり、タグも配列と文字列の両方が残っていました。 新しいスキーマに合わせて48本を一括修正すると、本文の移行とメタデータの整理が同じ差分に混ざります。

そこで、既存の表現をスキーマ側で受け止めました。 次のコードは実際の定義から主要部分を抜粋したものです。

const posts = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "../../content",
    generateId: ({ entry }) => legacySlug(entry),
  }),
  schema: z.object({
    title: z.string().min(1),
    description: z.string().optional(),
    excerpt: z
      .string()
      .nullish()
      .transform((value) => value || ""),
    publishedAt: z.coerce.date(),
    tags: z
      .union([z.array(z.string()), z.string()])
      .default([])
      .transform((tags) => (typeof tags === "string" ? [tags] : tags)),
    category: z.string().default("uncategorized"),
    draft: z.boolean().default(false),
  }),
});

descriptionexcerptのどちらを採用するかは、記事を取得する共通処理で決めています。 ページ、検索、RSSがそれぞれ独自に判定しないため、表示先による差も生まれません。

Content Collectionsとglob()ローダーの仕様は、公式ドキュメントを参照しました。

URLの互換性で見落としやすかったこと

記事ページはsrc/pages/blog/[slug].astroから静的生成します。 Astroの静的モードでは、動的ルートが生成するURLをgetStaticPaths()で列挙します。

---
import { getPosts } from "../../lib/posts";
import Post from "../../components/Post.astro";

export async function getStaticPaths() {
  return (await getPosts()).map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
---

<Post post={post} />

ここで、Astroが生成するIDをそのままURLに使うと、過去のURLと一致するとは限りません。 このブログではファイル名の先頭に日付を付け、日付を除いた部分をslugとして使っていました。

たとえば、次の記事です。

content/2021/2021-08-13-aws_iam_basic.md

/blog/aws_iam_basic

アンダースコアをハイフンへ変換すると、見た目は整っても公開済みURLが別物になります。 そこで、日付と拡張子だけを削るlegacySlug()glob()ローダーのgenerateIdへ渡しました。

export function legacySlug(filename) {
  return path
    .basename(filename)
    .replace(/^\d{4}-\d{2}-\d{2}-(.*)\.mdx?$/, "$1");
}

カテゴリを含む旧形式の/<category>/<slug>も静的生成しています。 どちらのURLも同じ記事コンポーネントを使い、canonical URLは/blog/<slug>へ揃えました。

タグURLにも似た問題がありました。 C++をURLへ埋め込むときに、ページを作る側とリンクを作る側で別々にエンコードすると、+が二重に変換されます。 この処理は小さな関数へまとめ、C++と日本語タグをテストケースに入れています。

Markdownは記事ごと直さず、互換処理を入れた

過去記事には、コードブロックへファイル名を付ける次の記法があります。

```python:example.py
print("hello")
```

python:example.pyを言語名としてShikiへ渡しても、Pythonのコードとして認識されません。 remarkプラグインでコロンの前を言語名、後ろをコードブロックのタイトルへ分けています。

Mermaidにも互換処理が必要でした。 コードフェンスを<pre class="mermaid">へ変換し、該当する要素がある記事だけMermaidを読み込みます。

const nodes = document.querySelectorAll(".mermaid");

if (nodes.length) {
  const { default: mermaid } = await import("mermaid");
  mermaid.initialize({
    startOnLoad: false,
    securityLevel: "strict",
  });
  await mermaid.run({ nodes });
}

記事を一括置換する方法もあります。 ただし、変換に失敗した記法が一つでもあると、元の記事との比較が必要になります。 今回は読み取り側に互換処理を置き、過去記事そのものには手を入れませんでした。

APIだけをリクエスト時に動かす

記事、タグ一覧、RSS、サイトマップ、検索インデックスはビルド時に作れます。 投票とOG画像は閲覧時の入力を使うため、同じ扱いにはできません。

この二つのAPIだけにprerender = falseを指定しています。

import type { APIRoute } from "astro";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  // リクエストを検証してVercel KVへ保存する
  return Response.json({ helpful: 1, not_helpful: 0 });
};

検索ではサーバーAPIを使っていません。 記事のタイトル、概要、URL、タグ、本文を含むsearch-index.jsonをビルドし、ブラウザ上で検索語と照合します。 記事を追加すれば再ビルドが走るため、このブログの更新頻度なら検索用データの鮮度も問題になりません。

ビルド成功だけでは移行完了にしない

Astroのビルドが通っても、移行が成功したとは判断できません。 存在するMarkdownをすべて読み込めているか、下書きを公開していないか、内部リンクが切れていないかは別の確認が必要です。

ビルド後のHTML、RSS、サイトマップ、検索インデックスを読み、次の内容を検査するスクリプトを追加しました。

  • 公開記事について、新旧二つのURLにHTMLがある
  • 下書きのHTMLが生成されず、検索やサイトマップにも含まれない
  • 各記事にdescription、canonical URL、OG画像、構造化データがある
  • RSSの記事数が公開記事数と一致する
  • 内部リンク、見出しリンク、画像の参照先が存在する
  • publicのファイルがビルド結果へコピーされている

CIでは、型チェックや単体テストの後にこの検査を実行します。

- run: npm ci
- run: npm run format:check
- run: npm run check
- run: npm test
- run: npm run test:content
- run: npm run verify

この検査はAstroの正しさを確認しているのではありません。 読者が以前と同じURLで記事を読めることと、運用に必要な出力が揃っていることを確認しています。 次に別の仕組みへ移すことがあっても、この検査はそのまま完了条件として使えます。

移行を終えて

Astroへ移す作業の多くは、.tsx.astroへ書き換えることではありませんでした。 実際に時間を使ったのは、slugの作り方、タグのエンコード、古いMarkdown記法、Vercelの出力先といった境界部分です。

Astroの構文は公式ドキュメントを読めば追えます。 しかし、aws_iam_basicのアンダースコアを残す理由や、過去記事が使っているコードフェンスの種類は、自分のリポジトリを調べなければ分かりません。 公開後に壊れやすいのは、このように以前の実装が暗黙に守っていた部分です。

Astroは、MarkdownをGitで管理し、更新時にビルドするこのブログには合っていました。 一方、管理画面で頻繁に記事を更新したい場合や、閲覧者ごとに本文を変える場合は、同じ構成をそのまま使えません。 外部CMS、SSR、キャッシュの設計が別に必要になります。

記事はビルド時に作り、状態を扱うAPIだけをリクエスト時に動かす。 そのうえで、移行前から存在するURLと記事表現をテストで守る。 これが今回決めた線引きです。

フレームワークを選んだ理由は時間がたつと曖昧になりますが、この線引きはコードと検査項目に残ります。 次の改修でも、どこまで静的に保ち、何を互換性として守るのかを同じ基準で判断できます。

広告