はじめに
実店舗を伴うクライアントのサービスLPに、フロントエンドにAstro、バックエンドにWordPress(Headless CMS) という構成を採用した。
クライアントの要件は以下の通り:
- コンテンツをクライアント自身で更新したい
- 表示速度を最大限に高速化したい
- 運用コストは最小限にしたい(既存のレンタルサーバーを使いたい)
WordPressの管理画面は使いたいが、WordPressのフロントは遅い。Astroで静的HTMLを生成すれば爆速になるが、データ更新のたびにエンジニアがビルドするのは現実的ではない。
この記事では、この課題をどう解決したかを実装レベルで解説する。後半では実際にハマった問題とその解決策もまとめた。
全体アーキテクチャ
┌──────────────┐ REST API ┌──────────────┐
│ WordPress │ ◄──────────────── │ Astro │
│ (Headless) │ ビルド時にfetch │ (SSG/静的) │
│ │ │ │
│ CPT + ACF │ │ pages/ │
│ カスタムAPI │ │ Tailwind CSS│
└──────┬───────┘ └──────┬───────┘
│ │
│ Webhook │ FTP Deploy
│ (repository_dispatch) │ (差分転送)
│ │
└──────► GitHub Actions ◄───────────┘
npm run build
ポイント:
- WordPressはREST APIサーバーとしてのみ使用。フロントは301リダイレクトでAstroサイトへ飛ばす
- Astroはビルド時にWordPressのAPIからデータをfetchし、完全な静的HTMLを生成
- WordPress側で記事を公開/更新すると、Webhookが発火してGitHub Actionsでビルド→FTPデプロイが自動実行される
技術スタック
| 役割 | 技術 |
|---|---|
| フロントエンド | Astro 5 (SSG) |
| スタイリング | Tailwind CSS 3 |
| CMS | WordPress + ACF (Advanced Custom Fields) |
| ホスティング | レンタルサーバー(共用) |
| CI/CD | GitHub Actions → FTP差分デプロイ |
WordPress側の実装
REST APIフィールドの拡張
WordPressの標準REST APIは title や content などの基本フィールドしか返さない。ACFのカスタムフィールドを含めた統合レスポンスを返すため、register_rest_field でカスタムフィールドを追加した。
register_rest_field(
'result',
'result_data',
array(
'get_callback' => array( $this, 'get_result_data' ),
'schema' => array(
'description' => '実績の統合データ',
'type' => 'object',
),
)
);
コールバック関数でACFフィールドと画像データを1つのオブジェクトにまとめて返す。
public function get_result_data( $post ) {
$post_id = absint( $post['id'] );
return array(
'item_name' => sanitize_text_field( $this->get_meta( $post_id, 'item_name' ) ),
'price' => sanitize_text_field( $this->get_meta( $post_id, 'price' ) ),
'category' => sanitize_text_field( $this->get_meta( $post_id, 'category' ) ),
'date' => sanitize_text_field( $this->get_meta( $post_id, 'result_date' ) ),
'description'=> wp_kses_post( $description ),
'image' => $image_data, // ACF画像 or アイキャッチのフォールバック
);
}
注意: REST APIのレスポンスでも sanitize_text_field() や esc_url() によるサニタイズは必須。APIはブラウザから直接叩ける公開エンドポイントなので、XSS対策を怠ってはいけない。
グルーピング済みデータのカスタムエンドポイント
店舗データはエリアでグルーピングした形で返したかったので、標準のREST APIではなくカスタムエンドポイントを作成した。
register_rest_route(
'mysite/v1',
'/stores',
array(
'methods' => 'GET',
'callback' => 'get_grouped_stores',
'permission_callback' => '__return_true',
)
);
レスポンスは以下のような構造になる。
[
{
"name": "東京都",
"default_open": true,
"stores": [
{
"name": "渋谷店",
"phone": "0120-000-000",
"access": "JR渋谷駅より徒歩3分",
"image": "https://example.com/wp/wp-content/uploads/store-01.jpg",
"url": "https://example.com/shop/shibuya/"
}
]
}
]
標準APIだとフロント側でグルーピング処理が必要になるが、カスタムエンドポイントで整形済みのデータを返せばフロントの実装がシンプルになる。
N+1問題への対処
グルーピング済みデータの取得では、エリアタームごとに店舗を取得するとN+1クエリが発生する。全店舗を1クエリで取得してからマッピングする方式にした。
// 全店舗を1クエリで取得
$all_stores = get_posts( array(
'post_type' => 'store',
'posts_per_page' => absint( self::MAX_STORES ),
'post_status' => 'publish',
'orderby' => 'menu_order',
'order' => 'ASC',
));
// 投稿IDでインデックス
$store_posts_by_id = array();
foreach ( $all_stores as $store_post ) {
$store_posts_by_id[ $store_post->ID ] = $store_post;
}
// ターム→投稿IDマッピングを一括取得
foreach ( $terms as $term_obj ) {
$post_ids_in_term = get_objects_in_term( $term_obj->term_id, 'area' );
// マッピング済みデータから組み立て
}
WordPressフロントのリダイレクト
Headless構成では、WordPressのフロントにアクセスされても表示するものがない。template_redirect フックでAstroサイト側にリダイレクトする。
add_action( 'template_redirect', 'headless_redirect' );
function headless_redirect() {
// 管理画面・REST API・cronは除外
if ( is_admin() || defined( 'REST_REQUEST' ) || defined( 'DOING_CRON' ) ) {
return;
}
$root_url = preg_replace( '#/wp/?$#', '/', untrailingslashit( site_url() ) );
wp_safe_redirect( esc_url( $root_url ), 301 );
exit;
}
管理画面・REST API・cronは除外するのがポイント。これを忘れると管理画面にもアクセスできなくなる。
Astro側の実装
データフェッチ層
WordPress REST APIからデータを取得するモジュールを src/lib/wordpress.ts に集約した。
// src/lib/wordpress.ts
const WP_API_URL = import.meta.env.WP_API_URL || '';
const WP_CUSTOM_API_URL = WP_API_URL
? WP_API_URL.replace(/\/wp\/v2\/?$/, '/mysite/v1')
: '';
const WP_FALLBACK_ENABLED = import.meta.env.WP_FALLBACK_ENABLED !== 'false';
export async function fetchResults(): Promise<Result[]> {
if (!WP_API_URL) {
if (WP_FALLBACK_ENABLED) {
return FALLBACK_DATA;
}
throw new Error('WP_API_URL is not configured and fallback is disabled');
}
try {
const url = `${WP_API_URL}/results?per_page=100&_fields=id,result_data`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`WP API error: ${response.status}`);
}
const data: WPResultResponse[] = await response.json();
return data.map(transformResponse);
} catch (error) {
console.error('Failed to fetch from WordPress:', error);
if (WP_FALLBACK_ENABLED) {
return FALLBACK_DATA;
}
throw error;
}
}
設計上の工夫:
-
_fieldsパラメータ:?_fields=id,result_dataで必要なフィールドだけ取得。レスポンスサイズが小さくなり、ビルド時間も短縮される -
フォールバック機構: ローカル開発時はWordPressが起動していなくてもビルドできるように、フォールバックデータを用意。
WP_FALLBACK_ENABLED=trueで有効化される -
本番CIではフォールバック無効化:
WP_FALLBACK_ENABLED=falseにすることで、APIエラー時にはビルドを失敗させる。フォールバックデータで本番に出てしまう事故を防ぐ
型定義
WordPress側のレスポンスとAstro側のドメインモデルを別の型として定義し、変換関数で橋渡しする。
// src/types/wordpress.ts
// WordPress REST APIのレスポンス型
export interface WPResultResponse {
id: number;
result_data: WPResultData;
}
// Astro側のドメインモデル
export interface Result {
name: string;
price: string;
category: string;
imageUrl?: string;
date?: string;
description?: string;
}
APIレスポンスの構造とフロントのコンポーネントが使うデータ構造を分離しておくことで、WordPress側のフィールド名が変わってもフロントへの影響を変換関数の修正だけに抑えられる。
反省: そもそもWordPress側のACFフィールド名を最初からAstro側と同じ命名(name / price 等)にしておけば、変換関数自体が不要だった。先にWordPress側を作り込んでからAstroを実装した結果、フィールド名がずれて変換層を挟む羽目になった。ClaudeCodeに任せてしまい、細かいところの確認が漏れていた弊害。
ページでのデータ利用
Astroのfrontmatterでデータを取得し、各コンポーネントにpropsで渡す。
---
// src/pages/index.astro
import { fetchResults, fetchStores } from '../lib/wordpress';
const results = await fetchResults();
const areas = await fetchStores();
---
<ResultsList results={results} />
<StoreList areas={areas} />
AstroのSSGでは、このfrontmatterはビルド時に一度だけ実行される。生成されるのは純粋な静的HTMLなので、ランタイムでAPIを叩くことはない。
CI/CD: WordPress更新→自動デプロイ
ここがこの構成の肝。WordPressで記事を公開/更新したら、Astroのビルドが自動で走り、本番に反映される仕組みを作った。
Webhookトリガー(WordPress側)
WordPressプラグイン内に、投稿の公開・更新・削除時にGitHub Actionsの repository_dispatch イベントを発火するクラスを実装した。
class Webhook_Trigger {
private static $dispatch_sent = false;
// トリガー対象のCPTを指定
private $trigger_post_types = array( 'result', 'store' );
public function __construct() {
add_action( 'transition_post_status', array( $this, 'maybe_trigger_webhook' ), 10, 3 );
add_action( 'save_post', array( $this, 'maybe_trigger_on_update' ), 10, 3 );
}
private function send_dispatch() {
// 1リクエストで1回だけ発火(重複防止)
if ( self::$dispatch_sent ) {
return;
}
self::$dispatch_sent = true;
$webhook_url = get_option( 'my_webhook_url', '' );
$github_token = get_option( 'my_github_token', '' );
wp_remote_post(
$webhook_url,
array(
'blocking' => false, // 非同期で発火
'headers' => array(
'Authorization' => 'Bearer ' . $github_token,
'Accept' => 'application/vnd.github+json',
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array(
'event_type' => 'wordpress_publish',
)),
)
);
}
}
設計上の工夫:
-
$dispatch_sentフラグで1リクエスト中の重複発火を防止。WordPressの保存処理ではtransition_post_statusとsave_postが両方発火するケースがある -
'blocking' => falseでWordPressの管理画面をブロックしない。APIコールの結果を待たずにレスポンスを返す - GitHub TokenとWebhook URLはWP管理画面の設定ページから入力できるようにした。ハードコーディングしない
GitHub Actionsワークフロー
# .github/workflows/build-deploy.yml
name: Build & Deploy
on:
repository_dispatch:
types: [wordpress_publish] # WordPressからのWebhook
push:
branches: [master] # コード変更時
workflow_dispatch: # 手動実行
concurrency:
group: build-deploy
cancel-in-progress: true # 同時ビルドを防止
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
env:
WP_API_URL: ${{ secrets.WP_API_URL }}
WP_FALLBACK_ENABLED: 'false'
- name: Deploy to FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_HOST }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./dist/
server-dir: example.com/
ポイント:
-
concurrencyで同時ビルドを制御。WordPressで連続更新しても、古いビルドはキャンセルされて最新のビルドだけが走る -
FTP-Deploy-Actionは差分デプロイ。変更されたファイルだけを転送するので、デプロイ時間が短く、ダウンタイムも発生しない -
WP_FALLBACK_ENABLED: 'false'でフォールバック無効化。CIでAPIエラーが出たらビルド自体を失敗させる
なぜ repository_dispatch を選んだか
GitHub ActionsのトリガーにはWebhook URLを直接叩く方法(workflow_dispatch)もあるが、repository_dispatch を選んだ理由:
-
workflow_dispatchはGitHub UIからの手動実行用で、APIから叩くにはワークフロー名の指定が必要 -
repository_dispatchはまさにこのユースケース(外部システムからのトリガー)のために設計されたイベント -
event_typeでイベントの種類を区別できるので、将来的にCPT別に処理を分けることも可能
ハマったポイントと解決策
実装してみると、ローカルでは問題なく動くのに本番環境やCI環境で次々と問題が発生した。ここではAstro×WordPress Headless構成に固有のハマりどころをまとめる。
1. レンタルサーバーのREST API海外アクセス制限
症状: GitHub Actions(海外IP)からビルドすると、WordPress REST APIが403を返す。ローカルでは正常にビルドできるのにCIだけ失敗する。
原因: 共用レンタルサーバーの「国外IPアクセス制限」が wp-json に対してもかかっていた。GitHub Actionsのランナーは海外IPなので弾かれる。
厄介な点: フォールバック機構があったため、ビルド自体は成功してしまった。APIエラー → フォールバックデータで静的HTML生成 → デプロイ成功。つまり本番サイトにハードコードのダミーデータがそのまま出ていた。しかもビルドログを注意深く読まないとフォールバックが使われたことに気づけない。
解決策:
- レンタルサーバーの管理画面で「REST APIへの海外アクセス制限」をOFFにする
-
本番CIでは
WP_FALLBACK_ENABLED=falseを必ず設定する。これにより、APIエラー時はビルド自体が失敗するようになり、フォールバックデータが本番に出る事故を防げる
教訓: フォールバック機構はローカル開発の利便性のためのもの。本番CIでフォールバックを有効にすると、APIの異常に気づけなくなる。
2. DNS切り替え前のCI環境でのビルド
症状: ドメインのDNSをまだ切り替えていない段階で、GitHub ActionsからWordPress REST APIにアクセスできない。ドメインが旧サーバーを向いているため。
試行錯誤の過程:
- IP直接アクセス + Hostヘッダー → レンタルサーバーのバーチャルホストがHostヘッダーを正しく処理しなかった
-
GitHub Actionsの
/etc/hostsにDNSオーバーライドを追加 → これで解決
# DNS切り替え前の一時的な対処
- name: DNS override
run: |
echo "157.120.209.30 example.com" | sudo tee -a /etc/hosts
解決策: DNS切り替え完了後にこのステップを削除。一時的な対処なので、コミットメッセージやIssueに「DNS切り替え後に削除する」と明記しておくのが重要。
教訓: SSGの構成では、ビルド環境からAPIサーバーへの名前解決が必要。DNS切り替え前は一時的な回避策が要る。Vercel等のプラットフォームを使う場合でも同じ問題は起きる。
3. リバースプロキシ環境のHTTPS検出
症状: WordPress REST APIが返す画像URLがすべて http:// になる。ブラウザでmixed contentエラーが発生。
原因: 共用レンタルサーバーはリバースプロキシの背後で動いており、WordPress(PHP)から見ると自分はHTTP(port 8080)で動いている。そのため site_url() や wp_get_attachment_url() がHTTP URLを返す。
解決策: wp-config.php に以下を追加。
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
ハマった点: これをCI/CDのYAMLから自動挿入しようとして、YAML→bash→Python→PHPの多段エスケープ地獄にハマった。$_SERVER がbashの変数展開に巻き込まれたり、ヒアドキュメント内でPHPの $ がエスケープされたり。結局4回コミットして壊し続けた。
最終的な対処:
- PHPコードはHEREDOCで別ファイルに書き出す
- Pythonスクリプトでファイル読み込み → 正規表現で安全に挿入
- CI/CDのYAMLから直接PHPコードを生成しない
4. Webhookが新規公開時しか発火しない
症状: WordPressで新しい記事を「公開」するとビルドが走るが、既に公開済みの記事の内容を「更新」してもビルドが走らない。
原因: 最初の実装では transition_post_status フックのみを使っていた。このフックは投稿ステータスが変わったとき(draft→publish等)だけ発火する。公開済み記事の内容を更新してもステータスは publish→publish のままなので、フックが発火しない。
解決策: save_post フックを追加して、公開済み投稿の内容更新時にもWebhookを発火するようにした。
public function __construct() {
// ステータス変更時(新規公開・削除)
add_action( 'transition_post_status', array( $this, 'maybe_trigger_webhook' ), 10, 3 );
// 公開済み投稿の内容更新時
add_action( 'save_post', array( $this, 'maybe_trigger_on_update' ), 10, 3 );
}
public function maybe_trigger_on_update( $post_id, $post, $update ) {
if ( ! $update ) return;
if ( 'publish' !== $post->post_status ) return;
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( wp_is_post_revision( $post_id ) ) return;
$this->send_dispatch();
}
注意点: transition_post_status と save_post が同時に発火するケースがある(新規公開時など)。$dispatch_sent フラグで1リクエスト中の重複発火を防止する必要がある。
5. WPS Hide Loginとの干渉
症状: WordPress管理画面のログインURLを隠すプラグイン「WPS Hide Login」を入れていたところ、CIからREST APIでの認証が通らなくなった。
原因: WPS Hide Login は wp-login.php へのアクセスを無効化するが、REST APIのApplication Password認証にも影響する場合がある。CIからWebhook設定を自動投入するスクリプトが、認証段階で弾かれていた。
試行錯誤:
- FTPでプラグインディレクトリをリネームして無効化 → 他のプラグインとの依存で失敗
- mu-pluginでDBのオプション値を直接書き換え →
WP_CACHE定数がget_optionの上書きを阻害 - mu-pluginで
option_active_pluginsフィルターから除外 → 成功
教訓: WPS Hide LoginはHeadless構成でも管理画面の保護に有効だが、CIからのREST API操作が必要な場面では干渉する可能性がある。導入タイミングに注意し、CI/CDパイプラインの構築が完了してからセキュリティプラグインを有効化するのが安全。
6. SSL証明書のドメイン不一致
症状: DNS切り替え後、HTTPS接続時にブラウザが証明書エラーを出す。
原因: レンタルサーバーのSSL証明書がデフォルト(共有ドメイン用)のまま。独自ドメイン用の証明書が設定されていなかった。
影響: Astroのビルド時にWordPress REST APIをHTTPSで叩けず、フォールバックデータでビルドされてしまう(ハマりポイント1と同じ症状)。
解決策: レンタルサーバーの管理画面から独自ドメイン用の無料SSL(Let's Encrypt)を有効化。
教訓: DNS切り替え時のチェックリストに「SSL証明書の設定」を必ず入れておく。切り替え直後はHTTPで動いているように見えるため、HTTPSの問題に気づくのが遅れがち。
運用の流れ
クライアントが行う操作は通常のWordPressと全く同じ。
- WP管理画面にログイン
- カスタム投稿の「新規追加」でデータを入力
- 「公開」ボタンをクリック
裏側では自動的に:
- WordPressプラグインがGitHub APIに
repository_dispatchを送信 - GitHub Actionsがビルドを開始
- AstroがWordPress REST APIからデータをfetchして静的HTML生成
- FTP差分デプロイで本番反映
公開ボタンから本番反映まで約2〜3分。クライアントはGitHubの存在すら知らなくていい。
まとめ
Astro + WordPress Headless CMSの構成は、以下のケースで特に有効だと感じた。
向いているケース:
- クライアントにWordPressの操作性を提供しつつ、表示速度を極限まで高めたい
- ページ数が少ない(LP、コーポレートサイトなど)
- 既存のレンタルサーバーを活用したい
想定より大変だったこと:
- レンタルサーバー固有の問題(海外IP制限、リバースプロキシ、SSL)がSSG+CIの構成と相性が悪い。VPSやクラウドなら起きない問題が多かった
- CI/CDのYAMLからWordPressの設定ファイルを操作するのは多段エスケープの罠が多い。別ファイルに分離すべき
- Webhookのトリガー条件は「新規公開」だけでなく「既存記事の更新」もカバーする必要がある。最初から両方実装しておくべきだった
- フォールバック機構は便利だが、本番CIでは無効化しないと障害を隠蔽する
とはいえ、一度仕組みを作ってしまえば運用は非常に楽。WordPressの管理画面で更新するだけで、Lighthouse 100点の静的サイトに自動反映される体験は、クライアントにもエンジニアにも嬉しい構成だった。