はじめに
きっかけは、知り合いが運用しているWordPressサイトについて「デザインを刷新したい」と相談を受けたことでした。
WordPress上でテーマを改修する選択肢もありましたが、表示速度・セキュリティ・今後の拡張性を考えると、この機会にヘッドレス構成(WordPressはデータソースに徹し、表示はAstro + Cloudflare Pagesで配信)へ移行したほうが良いと判断し、デザイン刷新とヘッドレス化を同時に進めることにしました。
対象は、固定ページ約40・投稿350件超・カスタム投稿タイプ・フォーム7枚、合計500URL超のサイトです。
着手からステージング環境の運用開始までは、週末のみの作業で完了しました。
実働は、AIコーディングエージェントによる実装作業が20時間程度、私が使ったのはレビューや判断などの3時間程度だけです。
この記事では、トップページのデザイン確定から、下層ページのテンプレート移行、フォームの移行、Cloudflare Pagesへのデプロイとステージング環境の運用開始まで、実際に踏んだ手順をステップごとにまとめます。
- 対象読者: WordPressサイトのヘッドレス化・Jamstack移行を検討している方、Astroで既存サイトをリプレースしたい方
- 得られるもの: 移行の全体工程(棚卸し→設計→段階移行→検証→デプロイ)と、各工程で実際にハマった点・回避策
- 前提知識: WordPressの基本(REST API・プラグイン)、Astroの基本(ルーティング・SSR)
実行環境は以下の通りです。
| 項目 | バージョン |
|---|---|
| Astro | v4.16系 |
@astrojs/cloudflare |
v11系 |
| Node.js | v20以上 |
| WordPress | 6系 |
| Wrangler(Cloudflare CLI) | v3系以降 |
本記事は特定の1サイトでの移行経験に基づきます。
サイトの規模・プラグイン構成によって最適解は変わるため、あくまで一つの実例として参考にしていただければと思います。
記事中のドメイン名・ID・パスは、実際の環境から架空の値(example.com等)に置き換えています。
要点(最初に結論)
- 移行は「一括切替」ではなくページ単位の段階公開にしました。Astro側のミドルウェアに公開許可リストを持ち、未移行ページは旧WordPressへ302リダイレクトで逃がします
- WordPressのページは3パターンに分類して移行方針を変えました。①完全再実装、②本文HTMLの再利用+再スタイル、③ページビルダー出力の再スタイルです
- データ取得層はキャッシュ+静的フォールバックを内蔵した1ファイルに集約しました。WordPress側が落ちてもトップページは表示が壊れません
- フォーム(Contact Form 7)はAstro側のAPIルートでプロキシし、CORS・Basic認証・許可リスト制御をサーバー側で吸収しました
- 検証は「ビルド成功」だけで判断せず、全URLクロール・複数画面幅のスクリーンショット・リンク切れゼロ確認を毎回のデプロイ前に実施しました
全体アーキテクチャ
最終的な構成は次の通りです。
ポイントは次の3つです。
- WordPressは表示に一切関与しません。コンテンツ(本文HTML・投稿一覧・メニュー・レビュー)をREST APIで返すだけです
-
Astro側は全ページSSR(サーバーサイドレンダリング)です。Cloudflare Pagesの
_worker.jsとして動き、記事の追加・修正はWordPress側の編集だけで反映されます - 認証情報はサーバー側で完結させます。ステージングWordPressのBasic認証はWorker側のfetchで付与し、ブラウザには一切渡しません
移行ステップの全体像
実際に踏んだ工程は、次のステップ0〜9の10工程です。
| ステップ | 内容 |
|---|---|
| 0 | 移行元サイトの棚卸し(全URL・全テンプレート・全フォームの一覧化) |
| 1 | トップページのデザイン確定(複数案の静的モック比較) |
| 2 | データ取得層の構築(REST API+キャッシュ+フォールバック) |
| 3 | 段階公開の仕組み(ミドルウェア許可リスト+302フォールバック) |
| 4 | 下層ページのテンプレート移行(3パターン分類) |
| 5 | 一覧・詳細・アーカイブページの移行 |
| 6 | フォームの移行(CF7プロキシ・レビュー投稿・住所補完) |
| 7 | SEO実装(sitemap・JSON-LD・canonical) |
| 8 | 検証(全URLクロール・レイアウトチェック) |
| 9 | Cloudflare Pagesデプロイ・ステージング運用開始 |
以下、順に説明します。
ステップ0: 移行元サイトの棚卸し
最初に、移行元WordPressの「全部」を一覧化します。
ここを雑にすると、後から移行漏れが発覚して手戻りになります(実際になりました。後述します)。
棚卸しの対象は以下です。
- 固定ページ(
/wp-json/wp/v2/pages?per_page=100) - 投稿とカテゴリ(
/wp-json/wp/v2/categories?per_page=100でcount > 0のカテゴリを全部確認する) - カスタム投稿タイプ(
/wp-json/wp/v2/types) - タクソノミーアーカイブ(カテゴリ別・タグ別・カスタムタクソノミー別の一覧ページ)
- フォーム(Contact Form 7なら
/wp-json/contact-form-7/v1/contact-forms) - テーマのウィジェットエリア(サイドバー・フッター。REST APIに乗らないのでHTMLを直接確認する)
固定ページの一覧だけを見て「全ページ把握した」と判断してはいけません。
私は当初、固定ページと主要カテゴリだけを棚卸しし、投稿カテゴリ2つ分のアーカイブページ(計60件超の記事)を見落としました。
「このページが表示されない」というユーザー報告で発覚し、後から追加移行しています。
カテゴリ一覧APIで記事数が1件以上あるカテゴリをすべて列挙し、それぞれにアーカイブページが存在するかを確認するのが確実です。
棚卸しの結果は、リポジトリ直下にMIGRATION-STATUS.mdのようなページ別ステータス台帳として残し、以後の進捗管理の正本にしました。
| パス | 種別 | 状態 |
|---|---|---|
| / | トップ | 公開済み |
| /item-list | 一覧(動的) | 公開済み |
| /faq | 固定(ページビルダー製) | 公開済み |
| /contact | フォーム(CF7) | 未移行(302フォールバック) |
ステップ1: トップページのデザイン確定
リニューアルを兼ねた移行だったため、トップページは複数のデザイン案から選ぶ進め方にしました。
- デザイン案を6種類、素のHTML+CSSの静的モックとして作成する
- Astroの静的ルート(
/poc/<案の名前>)で全案を同一URL配下に公開する - 公開範囲はBasic認証で保護する(後述のミドルウェアで制御)
- 実機(PC・スマホ)で見比べて1案を採用する
採用決定後、選ばれた静的モックをAstroコンポーネントへ変換し、ルート/で公開しました。
不採用案とPoC用ルートは、移行完了後にリポジトリごと削除しています。
この「静的モックを本番と同じ配信環境で比較する」方式は、デザインツール上の比較よりも判断が速く、採用後の実装コストも小さくて済みました。
モックのHTML/CSSがそのままAstroコンポーネントの土台になるためです。
ステップ2: データ取得層の構築
表示に必要なデータ(投稿・メニュー・レビュー等)の取得は、1ファイルのデータ取得層に集約しました。
設計方針は次の3点です。
- インメモリキャッシュ(TTL 10分)を持ち、同一Workerインスタンス内の連続リクエストでWordPressを叩き直さない
- 静的フォールバックを内蔵し、WordPressが落ちていてもトップページの表示が壊れない
- 参照先WordPressは環境変数で切り替える(未設定なら本番、設定すればステージング)
const WP_ORIGIN = import.meta.env.WP_ORIGIN ?? 'https://www.example.com';
const CACHE_TTL_MS = 10 * 60 * 1000;
const cache = new Map<string, { expires: number; data: unknown }>();
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
const hit = cache.get(path);
if (hit && hit.expires > Date.now()) return hit.data as T;
try {
const res = await fetch(`${WP_ORIGIN}${path}`, { headers: wpAuthHeaders() });
if (!res.ok) return fallback;
const data = (await res.json()) as T;
cache.set(path, { expires: Date.now() + CACHE_TTL_MS, data });
return data;
} catch {
return fallback; // ネットワーク断でもページは静的フォールバックで描画する
}
}
export function fetchLatestPosts(count: number) {
return fetchJson(
`/wp-json/wp/v2/posts?per_page=${count}&_embed&_fields=id,slug,title,date,_links,_embedded`,
FALLBACK_POSTS,
);
}
_fieldsパラメータで取得フィールドを絞ると、不要フィールドの生成処理とレスポンスサイズを削減できます。
_embedを付けると、アイキャッチ画像を含む埋め込み可能な関連リソースが_embeddedに追加され、1リクエストで取得できます。
_fieldsと併用する場合は、上のコード例のように_linksと_embeddedを取得対象へ含める必要があります。
罠: サーバーBasic認証とアプリケーションパスワードは同一リクエストで併用しにくい
ステージングWordPressはnginxのBasic認証で保護していました。
REST APIの認証にはWordPress標準のアプリケーションパスワードを使う想定でしたが、この構成では利用できませんでした。
原因は、アプリケーションパスワードもHTTP Basic認証として同じAuthorizationヘッダを使うためです。
nginx用とWordPress用で異なる2組のBasic認証情報を、通常の1リクエストに同時に載せることはできません。
サーバー側のBasic認証がヘッダを消費するため、WordPressまで認証情報が届かない構成になります。
なお、私の環境ではアプリケーションパスワードの発行欄自体が表示されませんでしたが、発行可否はHTTPS認識やwp_is_application_passwords_availableフィルター、セキュリティプラグイン等の条件で決まるため、これは環境固有の観測です。
同様の構成で発行欄が出ない場合は、これらの条件も併せて確認することを推奨します。
対応として、認証不要の読み取り専用カスタムRESTエンドポイントをCode Snippetsプラグインで設置しました。
公開して問題ないデータだけを返す設計にし、書き込み系は一切持たせません。
サーバーのBasic認証はWorker側のfetchで通します。
認証情報は環境変数から読み、ブラウザには渡しません。
自作RESTスニペットは2種類設置しました
標準のREST APIだけでは取れないデータが2つあり、それぞれ専用エンドポイントを自作しました。
ここは移行の中でも苦労した箇所なので、詳しく書きます。
| スニペット | 返すデータ | 使い道 |
|---|---|---|
| レビュー集計 | 承認済みレビューの平均・総件数・星別分布・最新N件(投稿者名・星・本文・添付画像) | トップページのレビューセクション表示と構造化データ(AggregateRating) |
| メニュー構造 | 管理画面で編集しているナビゲーションメニューの階層ツリー | ヘッダ・フッタのナビ描画 |
いずれも「WordPressの管理画面で編集した内容が、新サイトに自動反映される」状態を保つためのものです。
静的に写してしまえばスニペットは不要ですが、それでは運用担当者がメニューやレビューを更新するたびにコード修正が必要になってしまいます。
自作スニペット1: レビュー集計エンドポイント
サイトにはレビュープラグイン(Site Reviews)が入っており、トップページに総合評価・星別分布・最新レビューを表示する要件がありました。
このプラグインのデータは標準の/wp-json/wp/v2/*には露出しないため、プラグインが提供するPHP関数をスニペット内で呼び、表示に必要な形へ整形して返します。
add_action('rest_api_init', function () {
register_rest_route('mysite/v1', '/reviews', [
'methods' => 'GET',
'permission_callback' => '__return_true', // 承認済みの公開情報のみ返すため認証不要
'callback' => function () {
// プラグインのPHP関数で承認済みレビューを取得し、
// 平均・総件数・星別分布・最新N件(投稿者・星・本文・添付画像URL)へ整形して返す
return mysite_build_public_reviews_payload();
},
]);
});
設計上のポイントは、画面表示と構造化データ(JSON-LDのAggregateRating)を同じエンドポイントから作ることです。
別々のデータソースにすると、表示は4.5なのに構造化データは4.6、のような不一致が起き、検索エンジンのガイドライン違反になりかねません。
また、要件は後から増えます。
最初は「平均と最新N件」だけでしたが、実装を進めると投稿者のアバター・星別分布・添付画像も必要になり、スニペットを2回改訂しました。
スニペットの正本はWordPress管理画面ではなくリポジトリ側(wp-snippets/ディレクトリ等)に置き、管理画面へはコピペで反映する運用にすると、改訂履歴が追えて安全です。
本番WordPressへの切替時に同じスニペットを再設置する必要があるため、「どのスニペットをどこに設置したか」は移行台帳に記録しておきます。
自作スニペット2: メニュー構造エンドポイント
意外な落とし穴だったのがナビゲーションメニューです。
WordPress標準のREST APIにもメニュー取得のエンドポイント(/wp/v2/menus等)は存在しますが、認証必須(edit_theme_options権限)で、公開データとしては取得できません。
ヘッダ・フッタのナビを静的に書いてしまう手もありますが、運用担当者が管理画面でメニューを編集しても新サイトに反映されなくなります。
そこで、メニュー階層を認証なしで返す読み取り専用エンドポイントを設置しました。
add_action('rest_api_init', function () {
register_rest_route('mysite/v1', '/menus', [
'methods' => 'GET',
'permission_callback' => '__return_true', // メニューは公開ページに表示される情報のみ
'callback' => function () {
$locations = get_nav_menu_locations();
$result = [];
foreach (['header', 'footer'] as $key) {
$items = isset($locations[$key]) ? wp_get_nav_menu_items($locations[$key]) : [];
// parent IDを使ってフラットな配列を階層ツリーへ整形する
$result[$key] = mysite_build_menu_tree($items ?: []);
}
return $result;
},
]);
});
Astro側で受けるときのポイントは2つあります。
1つ目はURLの正規化です。
メニュー項目のURLは旧サイトの絶対URL(https://www.example.com/faq/)で返ってきます。
移行済みページの許可リスト(ミドルウェアと共有する定数ファイル)と突き合わせ、移行済みなら相対パスへ、未移行なら旧サイトの絶対URLのままにする変換関数を通します。
これを怠ると、ナビをクリックするたびに新旧サイトを行き来する動線になってしまいます。
2つ目はフォールバックの内蔵です。
スニペットはWordPress管理画面への手作業設置が必要なため、「コードはできたがスニペット未設置」の期間が生まれます。
データ取得層にメニューの静的フォールバックを持たせておくと、エンドポイント接続前でもナビが描画され、設置タイミングとデプロイの順序が独立します。
私は接続前後でナビのHTML出力が完全一致することをdiffで確認してから切り替えました。
ステップ3: 段階公開の仕組み(ここが移行の背骨)
500URL超のサイトを一括で切り替えるのは現実的でないため、ページ単位で公開範囲を広げていく方式にしました。
仕組みはAstroのミドルウェア1ファイルです。
import { defineMiddleware } from 'astro:middleware';
// 移行済み・公開するパス(完全一致)
export const PUBLIC_PATHS = ['/', '/faq', '/company-profile', '/api/contact'];
// 移行済み・公開するパス(前方一致。動的ルート用)
export const PUBLIC_PREFIXES = ['/item-list', '/item/', '/news/'];
// 未移行だが旧サイトに実体がある公開ページ → 302で旧サイトへ逃がす
const WP_FALLBACK_PATHS = ['/contact', '/special-lp'];
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = new URL(context.request.url);
// 末尾スラッシュを正規化してから判定する(重要・後述)
const path = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
if (PUBLIC_PATHS.includes(path)) return next();
if (PUBLIC_PREFIXES.some((p) => path === p.replace(/\/$/, '') || pathname.startsWith(p.endsWith('/') ? p : `${p}/`))) {
return next();
}
if (WP_FALLBACK_PATHS.includes(path)) {
return context.redirect(`https://www.example.com${pathname}`, 302);
}
return new Response('Temporarily unavailable.', { status: 503 });
});
この方式の利点は次の通りです。
- ページを1枚移行するたびに、許可リストへ1行足すだけで公開できます
- 未移行ページへのアクセスは旧サイトへ302で逃がすため、移行期間中もリンク切れが発生しません
- 移行が進むにつれて
WP_FALLBACK_PATHSが縮んでいき、残数がそのまま「残作業リスト」になります
末尾スラッシュの正規化を忘れないでください。
WordPressの正規URLは/faq/のように末尾スラッシュ付きです。
被リンクやブックマークからは両方の表記でアクセスが来ます。
当初は完全一致判定だけだったため、/faq/(スラッシュ付き)が許可リストの/faqとマッチせず503になる不具合を作り込みました。
判定前にパスを正規化する1行で解決します。
もう1点、Astro + ミドルウェア構成での注意です。
アクセス制御をミドルウェアで行う場合、全ページSSRにする必要があります。
一部ページをprerender = true(静的生成)にすると、ビルド時にミドルウェアが走って認証エラーページが静的HTMLとして焼き込まれる事故が起きました。
ステップ4: 下層ページのテンプレート移行(3パターン分類)
下層ページ約70枚を全部フルスクラッチで作り直すのは工数が見合いません。
ページの性質で3パターンに分類し、移行方針を変えました。
| パターン | 対象 | 方針 |
|---|---|---|
| 型1: 完全再実装 | 一覧・詳細などデータ駆動のページ | AstroコンポーネントとしてUIごと再実装 |
| 型2: 本文HTML再利用 | 標準エディタ製の固定ページ(規約・会社概要等) |
content.renderedをそのまま表示し、共通CSSで再スタイル |
| 型3: ページビルダー出力の再スタイル | Elementor等ページビルダー製のページ | レンダリング済みHTMLを軽く変換して表示し、最小限のCSSを当てる |
型の判定方法
どのページがどの型かは、REST APIのcontent.renderedの先頭を見れば判定できます。
Elementor製ページはdata-elementor-type属性を持つラッパーで始まります。
curl -s "https://www.example.com/wp-json/wp/v2/pages?slug=faq&_fields=content" \
| head -c 300
# "<div data-elementor-type=\"wp-page\" ..." → 型3
# "<p>..." や "<h2>..." で始まる → 型2
私はここを確認せずに「標準エディタ製だろう」と推測で移行仕様を書き、コードレビューで「実データはElementor製」と指摘されて手戻りしました。
1ページごとに実データを見てから型を決めるのが結局いちばん速いです。
型2: 本文HTML再利用方式
content.renderedをset:htmlで描画するコンポーネントを1つ作り、専用CSSを当てます。
---
const { html } = Astro.props;
---
<div class="wp-content" set:html={html} />
Astroのスコープ付き<style>は、set:htmlやJavaScriptで注入したHTMLには当たりません。
Astroはスコープ付きスタイルのセレクタにハッシュ属性(data-astro-cid-*)を付けますが、注入HTMLの要素にはその属性が付かないためです。
WordPress本文向けのCSSはis:globalにするか、別ファイルのグローバルCSS(例: wp-content.css)として読み込む必要があります。
スコープ付き<style>の中で.wp-content :global(h2) { ... }のように:global()セレクタを使う方法もあります。
古い記事には<figure style="width:940px">のような幅の焼き込みが残っていて、スマホで横スクロールが発生しました。
インラインのwidth指定をカスケードで上書きするのではなく、max-width: 100%で実際に使用される幅の上限を親要素の幅に制限します(widthとmax-widthは別プロパティで、使用幅の計算時にmax-widthが上限として適用されます)。
.wp-content figure {
margin: 18px 0;
max-width: 100%;
}
型3: ページビルダー出力の再スタイル方式
Elementor製ページは要素数が多く、完全再実装は現実的ではありません。
一方で「レンダリング済みHTMLをそのまま出す」だけでは、JS依存のウィジェット(アコーディオン等)が動きません。
そこで、サーバー側でHTMLを変換してから表示する方式にしました。
- アコーディオンウィジェット →
<details>/<summary>へ変換(JS不要で動く) - 動画ウィジェット → 標準の
<iframe>へ変換 - 記事一覧ウィジェットのページネーション → 除去(サーバー描画済みの一覧部分だけ残す)
- 本文内のルート相対リンク → 移行済みならそのまま、未移行なら旧サイトの絶対URLへ書き換え
- 表セルのインライン色指定 → 除去(新デザインのトーンに合わせる)
変換は正規表現ベースの関数群として1ファイルにまとめ、その上に最小限の再スタイルCSS(見出し帯・ボタン・余白)を当てます。
この方式の最大の利点は、WordPress側での本文編集がそのまま新サイトに反映されることです(キャッシュTTLの10分以内)。
運用担当者の編集フローを変えずに済みます。
ステップ5: 一覧・詳細・アーカイブページの移行
投稿系は型1(完全再実装)です。
Astroの動的ルートで実装します。
src/pages/
news-list.astro # 一覧(ページネーション付き)
news/[slug].astro # 詳細
item-list.astro # アイテム一覧
item/[slug].astro # アイテム詳細
category-a/index.astro # カテゴリ別アーカイブ
実装上のポイントは以下です。
-
ページネーションはクエリパラメータ(
?page=2)で受け、REST APIのper_page/offsetに変換します。クエリ条件に一致する総件数はレスポンスヘッダX-WP-Total、総ページ数はX-WP-TotalPagesから取得できます - 旧URLの別表記(大文字小文字違い・旧形式パス)は301リダイレクトで正規URLへ寄せます
- 存在しないslugは、旧サイトに実体がある可能性を考慮して302で旧サイトへ逃がします(移行期間中の安全網)
- タイトルのHTMLエンティティ(
&等)のデコードは共通関数1つに集約します。ページごとに書くと必ず漏れます
ステップ6: フォームの移行
フォームは移行の中でいちばん慎重に進めた領域です。
Contact Form 7はプロキシ方式で移行する
CF7はREST API(/wp-json/contact-form-7/v1/contact-forms/<数値ID>/feedback)を持っており、フォームの見た目だけAstroで再実装し、送信はこのAPIへ流せます。
IDはフォームの数値の内部ID(投稿ID)です。ショートコードに表示されるハッシュ形式の識別子とは別物なので、混同しないようにしてください。
ただしブラウザから直接POSTすると、CORSとステージングのBasic認証が壁になります。
そこでAstroのAPIルートでプロキシしました。
import type { APIRoute } from 'astro';
// 移行済みフォームのIDだけ許可する
const ALLOWED_FORM_IDS = new Set(['123', '456', '789']);
export const POST: APIRoute = async ({ request, url }) => {
const formId = url.searchParams.get('form') ?? '';
if (!ALLOWED_FORM_IDS.has(formId)) {
return new Response(JSON.stringify({ error: 'unknown form' }), { status: 400 });
}
// multipart/form-data をそのまま転送する(ファイル添付対応)
const body = await request.formData();
const upstream = await fetch(
`${WP_ORIGIN}/wp-json/contact-form-7/v1/contact-forms/${formId}/feedback`,
{ method: 'POST', headers: wpAuthHeaders(), body },
);
return new Response(await upstream.text(), {
status: upstream.status,
headers: { 'content-type': 'application/json' },
});
};
ポイントは3つあります。
- フォームIDの許可リストを持ち、未知のIDはWordPressへ転送する前に400で弾きます
-
FormDataをそのまま転送すると、ファイル添付フィールドもそのまま通ります - CF7のレスポンスは
statusフィールド(mail_sent/validation_failed等)を持つので、Astro側のフォームJSはこれを見て画面内にメッセージを出します。旧サイトのサンクスページ遷移は廃止し、画面内完結にしました
フィールドのname属性は、CF7のフォーム定義とすべて一致させる必要があります。
管理画面のフォーム定義(またはREST APIのproperties.form)から転記します。
チェックボックス必須項目・ファイルのaccept拡張子・プレースホルダ等も原本から忠実に写します。
動的トークンを要求するプラグインは「その都度採取」方式で移行する
レビュー投稿プラグイン(Site Reviews)のフォームは、CF7と違って静的に写せませんでした。
送信に必要なnonce(ワンタイムトークン)とフォーム署名がページ描画のたびに生成されるためです。
対応として、プロキシAPIが送信の都度、WordPress側のフォームページをGETして隠しフィールドを採取し、ユーザー入力と合成してから本体(admin-ajax.php)へPOSTする方式にしました。
ブラウザ → POST /api/review(ユーザー入力のみ)
↓
Worker が WP のフォームページを GET
↓ hidden 11個+ハニーポット名を正規表現で採取
採取値 + ユーザー入力 を合成
↓
WP admin-ajax.php へ POST → 結果をブラウザへ返す
実装で得た教訓を2つ挙げます。
-
成功判定は厳密にします。admin-ajax.phpはHTTP 200のまま失敗レスポンスを返す慣行があるため、「HTTPが200なら成功」と判定すると失敗を成功と誤通知します。レスポンスJSONの
success === trueのみを成功とみなします - ハニーポットフィールドの
nameも描画毎に変わるため、これも採取対象に含めます
郵便番号→住所の自動補完
会員登録系フォームには郵便番号からの住所自動補完がありました。
自前実装はせず、外部スクリプト(zipaddr(zipaddra.js))を利用しました。
入力欄のid属性(zip/pref/city等)を規約通りに付けるだけで自動束縛されます。
<script is:inline src="https://zipaddr.github.io/zipaddra.js" charset="UTF-8"></script>
Astroでは外部スクリプトにis:inlineを付けないと、ビルド時のバンドル処理対象になってしまう点に留意が必要です。
外部CDNのスクリプトを読み込む場合、本来はintegrity属性(Subresource Integrity)でハッシュ検証を付けるのが安全です。
SRIは取得したファイルの内容をハッシュで検証する仕組みのため、バージョン固定URLがなくても技術的には適用できます。
ただし、配信元が同じURLの内容を更新するとハッシュ不一致となり、ブラウザはスクリプトの読み込みを拒否します。
内容が予告なく変わりうるURLでは、更新のたびにハッシュを追従する運用が必要になります(クロスオリジンの場合は配信元のCORS対応とcrossorigin="anonymous"も必要です)。
このライブラリは不変URLを提供しておらず、ハッシュ追従を安定運用できないと判断したため、SRIなしで読み込むリスク(配信元の改ざんがそのまま自サイトに及ぶ)を認識した上で採用しています。
リスクを避けたい場合は、内容を確認したスクリプトを自サイトに複製して配信する(セルフホスト)か、住所補完APIをサーバー側で中継する構成を検討してください。
フォームの検証は「実送信禁止」を前提に組む
運用中サイトのフォームは、テスト送信がそのまま実メール発報・実データ登録につながります。
本プロジェクトでは実送信を全面禁止し、検証は次の二重ガード付きモックで行いました。
// ガード1: ネットワーク層で対象APIを遮断する
await context.route('**/api/contact*', (route) => route.abort());
// ガード2: ページ側のfetchをモンキーパッチし、送信ペイロードを記録して偽応答を返す
await page.addInitScript(() => {
const orig = window.fetch;
window.fetch = async (input, init) => {
if (String(input).includes('/api/contact')) {
window.__captured = init?.body; // 検証用にペイロードを保存
return new Response(JSON.stringify({ status: 'mail_sent', message: 'OK' }));
}
return orig(input, init);
};
});
これで「正しいペイロードが組み立てられているか」「成功・失敗の画面遷移が正しいか」までは自動検証できます。
実際にメールが届くかの最終確認だけは、公開判断者による手動テスト1回に限定しました。
ステップ7: SEO実装
移行でSEOを毀損しないため、以下を実装しました。
- canonicalは全ページでself-canonicalを出します。サイトのオリジンは定数1箇所で管理し、本番ドメイン切替時に1行で変えられるようにします
- JSON-LD(WebSite / WebPage / Corporation / BreadcrumbList / LocalBusiness / FAQPage)はビルダー関数として集約します。レビューの集計値(AggregateRating)は画面表示と同じデータソースから生成し、構造化データと表示の不一致を作りません
-
sitemap.xmlは自前のSSRエンドポイント(
src/pages/sitemap.xml.ts)で生成します。一覧系のURLはREST APIから動的に列挙するため、記事が増えても自動追従します(最終的に500URL超) -
robots.txtにSitemap行を入れ、検証中は全体を
noindexにしておき、公開判断が出たタイミングで解除します
export const GET: APIRoute = async () => {
const staticPaths = PUBLIC_PATHS.filter((p) => !p.startsWith('/api'));
const posts = await fetchAllSlugs(); // REST APIから全slugを取得
const urls = [...staticPaths, ...posts.map((s) => `/news/${s}`)];
const xml = buildSitemapXml(urls.map((p) => `${SITE_ORIGIN}${p}`));
return new Response(xml, { headers: { 'content-type': 'application/xml' } });
};
ステップ8: 検証(デプロイ前の定型チェック)
デプロイ前の検証は毎回同じ手順を回しました。
-
ビルド成否の判定は「
Complete!の出力」と「dist/_worker.jsの存在」の2点で行います。ログのエラー行数を数える方式は、grepの数え落としで一度誤判定しました -
ローカル実機確認は
wrangler pages dev distで行います。ただし再ビルド後に古いWorkerを配信し続けることがあるため、検証前にプロセスを再起動します - レイアウトチェックは、主要ページ×4画面幅(375 / 768 / 1080 / 1440px)でスクリーンショットを撮り、横スクロールの有無をスクリプトで機械判定します(最終的に42ページ×4幅=168チェック)
- 全URLクロールで、公開対象の全ページ(500超)が200を返すこと、内部リンク切れがゼロであることを確認します
横スクロール判定は次のような1行で機械化できます。
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
// overflow > 0 なら横スクロールが発生している
ステップ9: Cloudflare Pagesへのデプロイとステージング運用
デプロイはGit連携ではなく、Wranglerによる直接アップロード方式にしました。
npm run build
npx wrangler pages deploy dist --project-name=<プロジェクト名>
*.pages.devのURLがそのままステージング環境になります。
運用上の注意点を3つ挙げます。
import.meta.envで参照した値はビルド時に焼き込まれる
Astro v4のimport.meta.envは、Viteによりビルド時にインライン展開されます。
ステージングWordPressの認証情報を.envに入れてimport.meta.envで参照すると、その値が_worker.jsに焼き込まれます。
Workerバンドルはブラウザへ配信されないため即漏洩ではありませんが、distやビルド成果物を秘密情報として扱う必要が生じます。
より安全なのは、秘密情報をCloudflare PagesのSecretsとして登録し、Astro.locals.runtime.env(APIルートではcontext.locals.runtime.env)からランタイムに参照する構成です(@astrojs/cloudflare v11系)。
私のケースでは参照先切替用の変数が中心だったため、本番向けビルドでは該当の環境変数を意図的に外してビルドする運用にしましたが、これから構築するなら最初からランタイム参照を推奨します。
デプロイ直後はエッジ伝播の新旧混在がある
デプロイ直後の数十秒〜数分間、パスによって新旧のWorkerが混在して応答することがあります(200と503が混ざる等)。
「デプロイが壊れた」と誤診しないため、確認手順を次のように固定しました。
- まずデプロイ固有URL(
<hash>.<プロジェクト名>.pages.dev)で全パスを確認する(ここは常に新バージョン) - プロジェクトURL側は、収束するまでループで待ってから確認する
until [ "$(curl -s -o /dev/null -w '%{http_code}' https://<プロジェクト名>.pages.dev/faq)" = "200" ]; do
sleep 5
done
日本語ファイル名の画像はUnicode正規化で404になることがある
WordPressのメディアに日本語ファイル名の画像があると、サーバー上のファイル名がNFD正規化(macOSからのアップロード等)になっている場合があります。
NFCでURLエンコードしてリクエストすると404になります。
該当画像だけAstro側のpublic/に自前ホストして回避しました。
ハマりどころ一覧(再掲・一覧表)
本文中で触れた罠を一覧にまとめます。
| # | 罠 | 回避策 |
|---|---|---|
| 1 | 棚卸しで固定ページしか見ず、カテゴリアーカイブを見落とす | カテゴリ・CPT・タクソノミーまで走査する |
| 2 | スコープ付き<style>が注入HTMLに当たらない |
is:globalかグローバルCSSにする |
| 3 |
prerenderページにミドルウェアの認証結果が焼き込まれる |
アクセス制御をミドルウェアでやるなら全ページSSR |
| 4 | 末尾スラッシュ付きURLが許可リストにマッチせず503 | 判定前にパスを正規化する |
| 5 | アプリケーションパスワードがサーバーBasic認証とAuthorizationヘッダで競合 |
読み取り専用の自作RESTを設置する |
| 6 | ページビルダー製ページを標準エディタ製と誤認 |
content.rendered先頭のdata-elementor-typeで判定 |
| 7 | admin-ajax.phpがHTTP 200で失敗を返す | レスポンスJSONのsuccess === trueのみ成功扱い |
| 8 | 旧記事のwidth焼き込みでスマホ横スクロール |
.wp-content figure { max-width: 100% } |
| 9 |
wrangler pages devが古いWorkerを配信 |
検証前にプロセス再起動 |
| 10 | デプロイ直後のエッジ新旧混在を障害と誤診 | デプロイ固有URLで先に確認・収束待ち |
| 11 | 日本語ファイル名画像のNFD/NFC不一致で404 | 該当画像のみ自前ホスト |
| 12 |
import.meta.envの値がWorkerバンドルへ焼き込まれる |
秘密情報はCloudflare Secrets+ランタイム参照にする |
| 13 | 標準REST APIのメニュー取得は認証必須で公開データとして取れない | 読み取り専用の自作メニューエンドポイントを設置する |
進捗と課題の「正本」を分けて運用する
移行のような長期作業では、ドキュメントの置き場所を最初に決めておくと迷子になりません。
私は2ファイル体制にしました。
- MIGRATION-STATUS.md(進捗の正本): ページ別の移行状態。公開リストを変更するコミットで必ず同時更新する
- ISSUES.md(課題の正本): 未解決の課題・判断待ち・リスク受容事項の一覧。解決しても行を消さず、状態を「解決」に変えて経緯を残す
課題リストには「状態」列(判断待ち / 対応中 / 外部要因待ち / リスク受容 / 解決)を持たせました。
「技術的には未解決だが、リスクを認識した上で先に進む」という判断を明文化できるのがポイントです。
たとえば「スパム対策(reCAPTCHA)は旧サイトと同水準のまま、本番切替時にCloudflare Turnstileの導入をまとめて判断する」のような項目です。
まとめ
500URL超のWordPressサイトのヘッドレス化・Astro + Cloudflare Pages移行を、棚卸しからステージング運用開始までステップ0〜9の10工程・週末のみの作業(AIの実装約20時間+私のレビューなど3時間程度)で進めました。
短期間で完了できた要因は、実装をAIコーディングエージェントに任せて人間はレビューと判断に集中したことに加えて、フルスクラッチを避ける方針(ページの3パターン分類・段階公開)を最初に決めたことが大きいと感じています。
振り返って効果が大きかった判断は、次の3つです。
- ミドルウェア許可リスト+302フォールバックによる段階公開。移行期間中もリンク切れを出さず、残作業が常に可視化されます
- ページの3パターン分類。全ページのフルスクラッチ再実装を避けつつ、WordPress編集者の運用フローも維持できました
- 検証の定型化。ビルド成功だけを信じず、全URLクロールと複数幅レイアウトチェックを毎回回したことで、デプロイ後の障害はゼロでした
一方で、棚卸し漏れやテンプレート型の誤認など、「実データを見ずに推測で進めた箇所」はことごとく手戻りになりました。
ヘッドレス化は個々の技術要素よりも、移行対象の全量把握と段階公開の設計が成否を分けると感じています。
同じ構成での移行を検討している方の参考になれば幸いです。