概要
20年以上運用してきたWordPressブログ(661記事、2,515画像)をAstroに移行して、Cloudflare Pagesで配信するようにしました。ホスティング費用はゼロになり、デプロイは git push だけで完了するようになりました。
この記事では移行の背景、技術スタック、実際の作業手順、苦労した点をまとめます。
WordPressの何が問題だったのか
静的コンテンツなのにDBが必要
ブログの記事は基本的に静的コンテンツです。それなのにMySQLが必要で、DBのバックアップやメンテナンスが発生します。
スナップショットが面倒
WordPressのデータは3箇所に分散しています。
- MySQL - 記事本文、メタデータ
- wp-content/uploads/ - 画像ファイル
- テーマ・プラグイン - PHP設定
バックアップするには mysqldump + rsync が必要で、復元するにも両方を揃えないとサイトが壊れます。Gitで丸ごと管理できない。
画像のサムネイル地獄
WordPressは画像を1枚アップロードすると、自動的にサムネイルを大量生成します。
IMG_1234.jpg ← オリジナルIMG_1234-150x150.jpg ← thumbnailIMG_1234-300x200.jpg ← mediumIMG_1234-768x512.jpg ← medium_largeIMG_1234-1024x683.jpg ← largeIMG_1234-1536x1024.jpgIMG_1234-2048x1365.jpg...2,515枚のアップロードに対して、サムネイルまで含めると数万ファイルになります。実際にこのブログでは参照されていない画像ファイルが3,250個(数 GB)も蓄積していました。
LLMとの相性が悪い
LLMで記事を書いたり校正したりすることが増えてきましたが、WordPressだとDBの中にあるコンテンツを取り出してLLMに渡す必要があります。Markdownファイルならエディタやターミナルからそのまま渡せます。Claude Codeにディレクトリを渡すだけで記事を書いてもらえるのは圧倒的に楽です。
フロントエンドのアセットパイプラインがない
WordPressにはビルドパイプラインがないので、画像の最適化(WebP変換、リサイズ)をビルド時に自動実行する仕組みがありません。プラグインで対応できますが、PHPベースの処理なので遅いし制約が多い。
リンク切れ・ゴミファイルが検出しづらい
10年以上運用していると、記事から参照されなくなった画像やファイルがどんどん溜まっていきます。WordPressのメディアライブラリは「どの記事からも参照されていないファイル」を検出する機能がありません。
実際にこのブログを調査したところ、参照されていないファイルが 3,250個(数 GB) も蓄積していました。WordPressのDB内のHTMLと wp-content/uploads/ のファイルを突き合わせないと検出できないので非常に面倒です。
Astroに移行した後は、Markdownファイルからの参照をgrepで簡単に調べられるので、不要ファイルの検出がシンプルになりました。
移行先の検討
MicroCMS + Next.js
最初はHeadless CMSの MicroCMS と Next.js の組み合わせを検討しました。しかし、Vercelのホスティング費用がかかります。個人ブログにそこまでコストをかけたくないです。
Astro + Cloudflare Pages
Astroを試してみたところ、これがドンピシャでした。
まず小さめのWordPressサイトを5つほどAstroに移行してみて、問題がないことを確認してからメインブログの移行に取り掛かりました。トータルで10サイト近くAstroに移行しましたが、最後に残ったのが3サイトで、このメインブログはそのうちの1つです。
今回の移行では Claude Code(Opus 4.5)を全面的に活用しました。Opus 4.5は賢くて、移行作業の8割方は半自動で進められました。コンテンツ変換、frontmatter生成、リダイレクトルール作成、プラグイン実装といった定型作業は指示を出すだけでほぼ自動で完了し、半日で一通り動く状態になりました。ただし、残りの2割(UIの微調整、デザインの仕上げ、コンテンツの整理)にはもう半日かかりました。LLMが苦手なのは「見た目の良し悪し」や「コンテンツとして何を残すか」の判断で、そこは人間の目が必要です。
技術スタック
最終的に採用した構成です。
| レイヤー | 技術 | 用途 |
|---|---|---|
| SSG | Astro 5.18 | 静的サイト生成 |
| テーマ | Fuwari | ブログテンプレート |
| CSS | Tailwind CSS 4 | スタイリング |
| UI | Svelte 5 | インタラクティブコンポーネント |
| Markdown拡張 | MDX, remark, rehype | カスタムコンポーネント |
| 画像最適化 | sharp | ビルド時にWebP変換・リサイズ |
| 全文検索 | Pagefind | クライアントサイド検索 |
| コードハイライト | Expressive Code | シンタックスハイライト |
| 数式 | KaTeX | LaTeX数式レンダリング |
| コメント | Giscus | GitHub Discussions連携 |
| ホスティング | Cloudflare Pages | CDN配信(無料枠) |
| CI/CD | GitHub Actions | 自動ビルド・デプロイ |
| Linter | Biome | フォーマット・lint |
Remark/Rehypeプラグイン構成
Markdownの拡張にはremark/rehypeのプラグインを多数使っています。
markdown: { remarkPlugins: [ remarkMath, // LaTeX数式 remarkReadingTime, // 読了時間計算 remarkExcerpt, // 記事の抜粋生成 remarkDirective, // カスタムディレクティブ remarkSectionize, // セクション分割 remarkMdxGlobalComponents, ], rehypePlugins: [ rehypeKatex, // KaTeX数式レンダリング rehypeSlug, // 見出しにID付与 rehypeUnwrapImages, // 画像のリンクラッピング解除 rehypeImageSize, // 画像サイズ自動取得 [rehypeComponents, { // カスタムコンポーネント components: { github: GithubCardComponent, amazon: AmazonCardComponent, note: (x, y) => AdmonitionComponent(x, y, "note"), tip: (x, y) => AdmonitionComponent(x, y, "tip"), }, }], [rehypeAutolinkHeadings, { behavior: "append" }], ],},コンテンツスキーマ
Astroの Content Collections でfrontmatterのスキーマを定義しています。
const postsCollection = defineCollection({ schema: z.object({ title: z.string(), published: z.date(), updated: z.date().optional(), draft: z.boolean().optional().default(false), description: z.string().optional().default(""), image: z.string().optional().default(""), tags: z.array(z.string()).optional().default([]), category: z.string().optional().nullable().default(""), }),});WordPressの wp_posts テーブルにバラバラに格納されていたメタデータが、frontmatterとして1ファイルにまとまりました。
---title: ブログのCMSをWordPressからAstroに移行したpublished: 2026-03-10category: "Programming"tags: ["WordPress", "Astro", "Cloudflare"]image: "../../assets/uploads/2026/03/cover.png"---
本文はMarkdownで書く。これが全て。移行作業の実際
移行は74コミットで完了しました。Gitのログから主要な作業フェーズを振り返ります。
Phase 1: 初期移行
43a2057 feat: switch to Fuwari blog template with migrated contentbe433be feat: add blog uploads (images) via Git LFSf2f02b0 feat: set featured images from WordPress thumbnail metadata13c2cfd fix: correct featured image URLs using wp_get_attachment_url797fe8f fix: convert WordPress/Cocoon shortcodes to standard Markdown/HTMLClaude Codeに「WordPressのディレクトリをAstro Fuwariテーマで動くように移行して」と指示しました。WordPressのHTMLをMarkdownに変換して、frontmatterを生成する作業を自動でやってくれます。
ただし、一発でうまくいくわけではないです。WordPress/Cocoonテーマ固有のショートコードやブロックパターンの変換が必要でした。
Phase 2: 機能追加
ad849b0 feat: add Amazon MDX component, OGP, GTM, redirects, 404 pagea064cc3 feat: generate OG images for 39 posts without featured images6046ae5 feat: add Giscus comment system powered by GitHub Discussions88fea73 feat: add related posts, Japanese nav, remove GitHub link from headerWordPressで使っていた機能をAstroで再実装しました。
Amazonアフィリエイトコンポーネント
WordPressではAmazonリンクをプレーンなHTMLやプラグインで書いていましたが、Astroでは自作のrehypeプラグインでカスタムコンポーネントとして処理しています。Markdownで ::amazon{asin="..."} と書くだけで商品カードがレンダリングされます。
::amazon{asin="B0CKWS1VWR"}商品データ(タイトル、価格、画像URL、レビュー数)はAmazon PA-APIから事前にフェッチして amazon-products.json に保存しておき、ビルド時に読み込みます。
// src/plugins/rehype-component-amazon.mjs(抜粋)let amazonProducts = {};try { const dataPath = join(process.cwd(), "src", "data", "amazon-products.json"); amazonProducts = JSON.parse(readFileSync(dataPath, "utf-8"));} catch (error) { console.warn("[AMAZON] Failed to load amazon-products.json:", error.message);}
export function AmazonCardComponent(properties, children) { const asin = properties.asin; const productData = amazonProducts[asin]; const affiliateUrl = productData?.detailPageUrl || `https://www.amazon.co.jp/dp/${asin}?tag=${AFFILIATE_TAG}`; const title = productData?.title || ""; const imageUrl = productData?.imageUrl || ""; // ... hastscriptでカード要素を構築 return h("a", { class: "card-amazon", href: affiliateUrl, target: "_blank" }, [ nImage, h("div", { class: "az-info" }, [nTitle, ...priceElements, nButton]), ]);}MDXファイルでは <Amazon asin="..." /> というJSXコンポーネントも使えるように、remarkプラグインで自動importを挿入しています。
実際のレンダリング結果はこんな感じです。

Astro フロントエンド開発の教科書
URLの自動埋め込み(astro-embed)
YouTubeやX(Twitter)のURLをMarkdownにそのまま貼るだけで、自動的にembedカードに変換されます。astro-embedを使っています。
<!-- こう書くだけでYouTubeが埋め込まれる -->https://www.youtube.com/watch?v=9TVkrzxjNRo
<!-- Xのポストも同様 -->https://x.com/matsubokkuri/status/1893168256317563150WordPressのoEmbedと同じ体験がMarkdownで実現できます。
Phase 3: 画像最適化
7bf0764 feat: move images to src/assets for build-time optimization7214b99 refactor: move OG images from public/og to src/assets/og318eb07 chore: remove 3250 unreferenced upload files (566 MB)これが一番大変でした。WordPressの wp-content/uploads/ にあった画像を src/assets/uploads/ に移動することで、Astroのビルドパイプライン(sharp)による最適化対象にしました。
画像をpublicディレクトリからsrc/assetsに移すことで、ビルド時に自動的にWebP変換とリサイズが行われます。
参照されていない画像ファイルが3,250個(数 GB)もあったので、全て削除しました。さらにGit履歴からも git-filter-repo で完全に消して、リポジトリサイズを3.7GBから2.4GBに縮小しました。
Phase 4: URL互換性
1e74d38 refactor: rename multibyte post filenames to ASCII9a54209 fix: update redirects for renamed Japanese slug postsWordPressは /YYYY/MM/DD/slug/ というURL構造でしたが、Astroでは /posts/slug/ になります。SEO上の影響を最小化するため、973件の301リダイレクトルールを生成しました。
/2003/06/05/php/ /posts/php/ 301/2024/08/02/stop-using-url-shortener-services/ /posts/stop-using-url-shortener-services/ 301...canonical URLも旧WordPress形式で出力するようにして、Googleの検索インデックスを引き継ぎました。
const pubDate = entry.data.published;const yyyy = pubDate.getFullYear().toString();const mm = (pubDate.getMonth() + 1).toString().padStart(2, "0");const dd = pubDate.getDate().toString().padStart(2, "0");const canonicalUrl = new URL( `/${yyyy}/${mm}/${dd}/${entry.slug}/`, Astro.site).href;Phase 5: Tailwind CSS v3 → v4
36bfa03 feat: migrate Tailwind CSS v3 to v4d3f9e66 fix: move text-* utilities from @layer components to @utility for TW4Fuwariテーマ自体がTailwind CSS v3ベースだったので、v4への移行が必要でした。
Tailwind 4では @layer components の中に書いたスタイルの優先度が大きく変わりました。@layer 内のスタイルは @layer 外のスタイルより常に優先度が低いため、開発サーバーでは動くのに本番ビルドでスタイルが崩れるという厄介なバグにハマりました。
/* BAD: @layer内のスタイルは優先度が低い */@layer components { .meta-icon { @apply mr-1.5; /* 本番ビルドで効かない! */ }}
/* GOOD: @layer外に書く */.meta-icon { margin-right: 0.375rem; background-color: var(--btn-regular-bg); color: var(--btn-content);}Phase 6: 仕上げ
d49c8a2 feat: add social sharing, sponsor button, and improve Amazon cards1d10317 feat: add PA-API product data fetcher for Amazon affiliate cards20a7448 refactor: update 121 internal links from old WordPress URLs to new paths16ca3bf feat: add internal blog post link card component9185ee9 refactor: remove dead rehypeImageSize plugin, fix tweet script duplication最後の仕上げとして、以下の作業をしました。
- SNSシェアボタン: X(Twitter)シェアとはてなブックマークボタンを記事ページに追加
- Amazonアフィリエイト強化: Amazon PA-APIから商品データ(タイトル、価格、画像、レビュー数)を自動取得するスクリプトを作成
- 内部リンクカード: 自サイトへのURLを自動的にブログカード形式で表示するremarkプラグインを実装。OGPフェッチ不要で、ビルド時にfrontmatterから直接データを取得
- 旧URLの一括更新: 記事中の121箇所の旧WordPress URL(
/YYYY/MM/DD/slug/形式)を新しいパス(/posts/slug/形式)に書き換え - 画像のフルサイズ配信: ライトボックス(PhotoSwipe)でオリジナル解像度の画像を表示できるように、画像幅の800px制限を撤廃
- OGP画像の正常化: 記事のfeature imageを
og:imageに使用するように修正。フォールバック用のデフォルト画像も配置 - X(Twitter)埋め込み:
widgets.jsを動的ロードして、ツイートの画像・メディアが正しく表示されるように対応
デプロイパイプライン
GitHub Actionsでビルドして、Cloudflare Pagesにデプロイしています。
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm build # astro build && pagefind --site dist - uses: cloudflare/wrangler-action@v3 with: command: pages deploy dist --project-name=blog # CDNキャッシュも自動パージ - run: | curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \ -H "Authorization: Bearer $TOKEN" \ --data '{"hosts":["blog.teraren.com"]}'git push するだけで、ビルド → デプロイ → CDNキャッシュパージまで自動で走ります。
実際のGitHub Actionsワークフローの実行画面です。Biome、textlint、Build、Deployの4ジョブが並列・直列で実行されています。

WordPress機能の代替
「静的サイトだとWordPressの○○ができないのでは?」という懸念があると思います。主要な機能の代替方法をまとめます。
全文検索 → Pagefind
WordPressの検索はMySQL上で LIKE クエリを実行していましたが、静的サイトでも全文検索はできます。Pagefindはビルド時に静的なインデックスファイルを生成し、クライアントサイドでFTS(Full Text Search)を実行します。
{ "scripts": { "build": "astro build && pagefind --site dist" }}ビルドの最後に pagefind を実行するだけで、日本語対応の検索インデックスが自動生成されます。検索UIはSvelteで実装して、インクリメンタルサーチに対応しています。DBを使わない検索がこんなに手軽にできるとは思っていませんでした。
コメント → Giscus
WordPressの組み込みコメント機能の代わりにGiscusを使っています。GitHub Discussionsをバックエンドにしたコメントシステムで、記事のパス名にマッピングされます。
<script src="https://giscus.app/client.js" data-repo="matsubo/blog-giscus" data-mapping="pathname" data-theme="preferred_color_scheme" data-lang="ja" data-loading="lazy" async></script>コメントデータはGitHubに保存されるので、自前でDBを管理する必要がありません。ダークモードの切り替えにも postMessage で連動させています。
Before / After
| WordPress | Astro | |
|---|---|---|
| 記事数 | 661(MySQL内) | 664(.md/.mdx) |
| 画像 | 2,515 + サムネイル数万 | 1,632(最適化済み) |
| ホスティング | VPS (Docker) | Cloudflare Pages |
| デプロイ | 手動 or プラグイン | git push |
| バックアップ | mysqldump + rsync | git clone |
| コスト | ~$10/月 | $0 |
| 全文検索 | MySQL LIKE | Pagefind(クライアントサイド) |
| コメント | WordPress組み込み | Giscus(GitHub Discussions) |
| ビルド出力 | — | 349 MB(静的HTML) |
苦労した点
304記事のMarkdownフォーマット修正
WordPressからの変換で壊れたMarkdownを手作業で修正しました。インデントのずれ、HTMLタグの残骸、Cocoonショートコードの変換漏れなど。304記事のフォーマットを一括修正するコミットが生まれました。
画像パスの移行
WordPressは画像を wp-content/uploads/YYYY/MM/ に格納しますが、Astroで画像最適化を有効にするには src/assets/ 配下に移す必要があります。パスの書き換えと、参照されていない画像の特定が大変でした。
日本語スラッグの扱い
WordPressは日本語スラッグを許容しますが、ファイル名に日本語が入っているとビルドやデプロイで問題が起きます。マルチバイトのスラッグを全てASCIIにリネームして、リダイレクトルールを追加しました。
良い点
- コストゼロ: Cloudflare Pagesの無料枠で十分。月10ドルの節約
- 速い: 静的サイトでCDN配信。体感で明らかに速い
- Gitで管理:
git logで記事の変更履歴が全て追える - LLMフレンドリー: ディレクトリごとClaude Codeに渡すだけで記事を書いてもらえる
- アセット最適化: sharpによるビルド時WebP変換・リサイズ
悪い点
- WYSIWYGエディタがない: WordPressのJetpackアプリのように、スマホからサクッと記事を書けない。Markdownエディタが必要
- ビルド時間: 664記事のビルドはそれなりに時間がかかる。GitHub Actionsのランナーで数分
Lighthouseスコア
移行後のLighthouseスコアです。

| メトリクス | スコア |
|---|---|
| Performance | 85 |
| Accessibility | 93 |
| Best Practices | 81 |
| SEO | 100 |
| First Contentful Paint | 2.6s |
| Largest Contentful Paint | 3.4s |
| Total Blocking Time | 200ms |
| Cumulative Layout Shift | 0.001 |
SEOは満点。静的HTMLをCDNから配信しているので、CLSはほぼゼロです。PerformanceはSwupのページ遷移アニメーションやWebフォントの読み込みがあるので85ですが、体感は十分速いです。
個人ブログとZennの棲み分け
Astroで自前ブログを構築したものの、技術記事の発信先としてZennも併用しており、棲み分けは悩みどころです。
| 個人ブログ | Zenn | |
|---|---|---|
| 読者層 | 検索流入、既存読者 | Zennコミュニティ、エンジニア |
| SEO | 自分でコントロール可能 | Zennドメインの強さに乗れる |
| デザイン | 完全に自由 | Zennのフォーマット固定 |
| 収益化 | アフィリエイト自由 | バッジ(投げ銭) |
| 所有権 | 自分のドメイン・リポジトリ | プラットフォーム依存 |
現時点の方針としては、技術的にニッチな記事やガジェットレビュー、生活系の記事は個人ブログへ、汎用的な技術Tipsや他のエンジニアに届けたい記事はZennへ書くようにしています。ただ、明確な基準があるわけではなく、書きながら判断しているのが正直なところです。
textlintで日本語の品質を底上げ
移行後の仕上げとして、日本語文章のlinterであるtextlintを導入しました。コードにESLintがあるように、日本語の文章にもlinterを入れることで、タイポや読みにくい表現を機械的に検出できます。
導入したプラグイン
| パッケージ | 用途 |
|---|---|
| textlint-rule-preset-ja-technical-writing | 技術文書向けの日本語ルール集 |
| textlint-plugin-mdx | MDXファイルの解析対応 |
| textlint-filter-rule-comments | HTMLコメントによるルール無効化 |
設定方針
テックブログは書籍のような堅い文体を求められないため、ガチガチのフォーマットルールは無効にし、読みやすさとタイポ検出に絞りました。
有効にしたルール(読みやすさ・タイポ):
ja-no-successive-word— 同じ単語の連続(「にに」「をを」等のタイポ)ja-no-redundant-expression— 冗長表現(「○○を行う」→「○○する」)
no-unmatched-pair— 括弧の閉じ忘れsentence-length— 長すぎる文(200文字超)no-dropping-the-ra— ら抜き言葉no-invalid-control-character— 不正な制御文字
無効にしたルール(ブログには不要):
no-mix-dearu-desumasu— ですます/である調の混在(ブログでは自然)preset-jtf-style— JTF日本語標準スタイルガイド全般(半角/全角かっこ、箇条書き句点統一など)max-ten/max-comma— 読点・カンマの数制限
効果
661記事に対して実行したところ、初回で約300件のエラーが検出されました。内訳は冗長表現、タイポ(助詞の重複や文字の二重入力)、括弧の閉じ忘れが大半です。WordPress時代には気づけなかった問題が一括で洗い出せたのは、Markdownファイルとしてファイルシステム上にコンテンツがあるからこそです。
CIにもBiomeと並列でtextlintを実行するジョブを追加しているため、今後の記事でも品質が自動的に担保されます。
// .textlintrc.json(抜粋){ "rules": { "preset-ja-technical-writing": { "sentence-length": { "max": 200 }, "no-mix-dearu-desumasu": false, "ja-no-successive-word": { "allow": ["。", "!", "?", "…", "・", "○"] }, "ja-no-redundant-expression": true, "no-unmatched-pair": true, "no-dropping-the-ra": true }, "preset-jtf-style": false }}まとめ
WordPressからAstroへの移行は大正解でした。
20年分のコンテンツをMarkdownファイルとしてGitリポジトリに格納し、Cloudflare CDNから配信する。DBもVPSも不要。Claude Codeに移行作業を手伝ってもらったおかげで、661記事の移行も現実的な工数で完了しました。
LLMの活用が進む中で、コンテンツがMarkdownファイルとしてファイルシステム上にあることの価値はますます大きくなると思います。WordPressのDB内にコンテンツが閉じ込められている状態は、もう時代に合っていないと感じます。