
この記事の結論(先に要点)
一番ハマったのは「Vercel の SSR から Xサーバー上の WordPress REST API に届かない」問題。
ブラウザからは叩けるのに、Vercel サーバー経由だと Waiting for response のままハングする。
原因は Vercel サーバーレス(動的IP)× Xサーバーの自動Bot検知。 Xサーバー側で明示的にブロックしていなくても、毎回変わる IP が自動的に弾かれていた。
解決は Nuxt の server/api/ プロキシ。 WordPress へのリクエストを「ブラウザ/Vercel → WordPress」から「Vercel 内の API ルート → WordPress」に付け替えるだけ。SEO 影響はゼロ。
対象読者は、Nuxt(Vue 3)でヘッドレス WordPress 構成を組もうとしているフロントエンド寄りのエンジニアです。
同じ構成で同じ壁にぶつかる人は確実にいるはずなので、原因の切り分け方まで含めて残しておきます。
目次Table of contents
構成
まず前提の技術スタックです。
| レイヤー | 採用技術 |
| フロント | Nuxt 3(Vue 3 / SSR) |
| コンテンツ | WordPress REST API(ヘッドレス運用) |
| ホスティング(フロント) | Vercel(Hobby プラン) |
| ホスティング(WordPress) | Xサーバー(共用サーバー) |
「フロントは Vercel で速く配信、コンテンツ管理は使い慣れた WordPress」という、よくある分離構成です。
ローカルでは完璧に動いていました。
問題は本番デプロイ後に起きます。
ハマりポイント①:Vercel の SSR から WordPress REST API に届かない
症状
デプロイ後、お知らせ一覧(`post_news`)が**真っ白**。エラーは出ず、ネットワークタブを見ると WordPress へのリクエストが `Waiting for response` のまま固まっている。ローカルでは一切再現しない。
切り分け:どこで切れているのかを確定させる
闇雲に設定をいじる前に、どの経路が生きていてどの経路が死んでいるかをはっきりさせました。
ブラウザのアドレスバーから REST API を直接叩いてみます。
https://<wordpressドメイン>/wp-json/wp/v2/post_news
結果、JSON は普通に返ってくる。
つまり状況はこう確定しました。
ブラウザ → WordPress ✅ 届く
Vercel サーバー → WordPress ❌ 届かない(Waiting for response)
ここが切り分けの肝です。
「WordPress が壊れている」のではなく「Vercel のサーバーサイド(SSR)からのアクセスだけが通らない」。
クライアントからは見えているのに SSR でこける、という非対称性が原因特定の決め手になりました。
Xサーバー側を一通り潰す
サーバー側でブロックしている可能性を順番に確認しました。
– Xサーバーパネルの WAF設定 → すべて OFF
– Wordfence などのセキュリティプラグイン → 未導入
– WordPress 設定 → 表示設定の 「検索エンジンがインデックスしないようにする」 → チェックなし
つまり、Xサーバーは意図的なブロックは何もしていない。それでも Vercel からだけ届かない。
原因:動的IP × 自動Bot検知
調べていくと、原因は構成そのものにありました。
Vercel のサーバーレス関数(AWS Lambda ベース)は、リクエストのたびに送信元 IP が変わる「動的 IP」を使う。
Xサーバーの自動Bot検知が、この毎回変わる素性の知れない IP を「不審なアクセス」と判断して自動遮断していた。
Xサーバーの管理画面に「この IP をブロック中」と出るわけではありません。自動検知による暗黙のブロックなので、設定画面をいくら見ても原因が見えない。ここが沼でした。
検討して捨てた案:Vercel Static IPs
「動的 IP が問題なら固定すればいい」——その通りで、Vercel には **Static IPs** という固定 IP 機能があります。
固定した IP を Xサーバーの許可リストに入れれば根本解決できます。
ただし料金を確認したところ、**Static IPs は Pro プラン($20/月〜)とは別に +$100/月/プロジェクト**。個人のポートフォリオに月100ドルは現実的ではないので却下しました。
(※料金は変動するので、採用検討時は[Vercel公式の料金ページ]で最新を確認してください。
採用した解決策:Nuxt の `server/api/` プロキシ
無料かつ数分で終わる現実解がこれでした。WordPress への fetch を Nuxt 自身のサーバールートで一段プロキシする。
ポイントは、Nuxt 3 では `server/api/` 配下に置いたファイルが自動的に API エンドポイントになること([公式ドキュメント:Server Directory])。そしてこのサーバールートは Vercel 上で動く Nuxt 自身なので、SSR からは必ず到達できます。
【今までの問題構成】
Vercel SSR → WordPress API(動的IPが弾かれて届かない ❌)
【プロキシ後】
Vercel SSR → /api/wp/…(同じVercel内なので必ず届く ✅)
wp/→ ここから WordPress API へ fetch(サーバー間で通る ✅)
エンドポイントごとにファイルを作るのは面倒なので、キャッチオール(`[…slug]`)で全エンドポイントを1ファイルに集約しました。
ts
// server/api/wp/[…slug].ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, ‘slug’) // 例: post_news, post_works
const query = getQuery(event) // ページネーション等のクエリ
const config = useRuntimeConfig()return await $fetch(slug, {
baseURL: config.public.apiBase, // 例: https://<wp>/wp-json/wp/v2/
params: query,
})
})
呼び出し側は、エンドポイントの先頭を `/api/wp/` に差し替えるだけです。
ts
// 変更前:WordPress に直接(SSRで死ぬ)
const { data } = await useFetch(‘post_news’, {
baseURL: config.public.apiBase,
})// 変更後:Nuxt のプロキシ経由(SSRでも通る)
const { data } = await useFetch(‘/api/wp/post_news’, {
params: newsParams,
watch: [newsParams],
default: () => [],
})
ハマりの中のハマり:`$fetch.raw` の書き換え漏れ
ここで一度ハマり直しました。
一覧のページネーションで、レスポンスヘッダー(`x-wp-total` / `x-wp-totalpages`)を取るために `useFetch` ではなく `$fetch.raw` を使っている箇所があり、これも同じく `/api/wp/` に書き換えないと、その部分だけ SSR でこけ続けます。
`useFetch` だけ直して「直った!」と思ったら一覧のページ送りだけ壊れている、という罠です。
WordPress を叩いている箇所は `grep` で全部洗い出してから一括で付け替えるのが安全です。
“`bash
grep -rn “apiBase\|\$fetch.raw” components/ pages/
“`
なぜページネーションでわざわざ `$fetch.raw` が必要なのかは、それ自体が独立したハマりポイントなので**次章②でまるごと解説**します。
「これ、SEO 的に大丈夫?」への答え
プロキシを噛ませると聞くと SEO が不安になりますが、**影響はありません**。クローラーが見るのは**最終的に返ってくる HTML だけ**だからです。
“`
クローラー → Vercel → /api/wp/…(プロキシ)→ WordPress → データ取得
↓
HTML に描画済みで返す ← クローラーが見るのはここだけ
“`
データが HTML に埋め込まれた状態でレスポンスされる以上、途中でプロキシを経由したかどうかはクローラーには無関係。
SEO に悪いのは `server: false` や `lazy: true` で「HTML が空のまま返ってクライアントで後から差し込む」パターンであって、今回はその逆(SSR で中身を埋めて返す)なので問題ない、という理解です。
教訓: ローカルで動いても本番(特にサーバーレス)で死ぬ問題は「動的IP × 受け側の自動防御」を疑う。
そして切り分けは “ブラウザから直接叩く” が最速。
ハマりポイント②:総ページ数がレスポンスボディに入っていない(ページネーション)
ここが地味に一番「えっ」となったポイントです。
一覧にページャーを付けようとして、WordPress REST API のレスポンス JSON に総ページ数が入っていないことに気づきます。
投稿データの配列は返ってくるのに、「全部で何ページあるか」がボディのどこにもない。
総ページ数は HTTP ヘッダにある
| ヘッダ | 内容 |
| x-wp-total | 全件数 |
| x-wp-totalpages | 総ページ数 |
つまりページャーを作るには、ボディ(投稿配列)とヘッダ(総ページ数)を両方拾う必要があります。
ところが `$fetch` や `useFetch` は基本的にボディしか返さず、ヘッダにアクセスできません。
解決:`$fetch.raw` でヘッダごと取得し、`useAsyncData` でまとめて返す
ヘッダを読むには `$fetch.raw` を使います。返ってくる `res` から、ボディは `res._data`、ヘッダは `res.headers.get(…)` で取り出し、1つのオブジェクトにまとめて返すのがポイントです(SSR で確実に走らせるため `useAsyncData` で包みます)。
ts
const {
data: works,
error: worksError,
pending,
} = await useAsyncData(“works”, async () => {
const res = await $fetch.raw(“/api/wp/post_works”, {
params,
});
return {
data: res._data,
“x-wp-totalpages”: res.headers.get(“x-wp-totalpages”),
};
});
これで `works.data` に投稿配列、`works[‘x-wp-totalpages’]` に総ページ数が入ります。
地味な罠:ヘッダは「文字列」で返ってくる
ここで一度ハマりました。`x-wp-totalpages` は文字列で返ってくるので、そのままページ数計算に使うと比較やループがおかしくなります。
子コンポーネントに渡す時点で `Number()` で型変換しておきます。
vue
<PartsPager :totalPages=”Number(works?.[‘x-wp-totalpages’])” />
型を `Number` で定義したプロップに文字列を渡しても Vue は黙って受け取ってしまうので、「なんかページャーの挙動が変」で気づくまで時間を溶かしがちな、典型的な静かなバグです。
ページャーはコンポーネント化して再利用する
ページャーは一覧ページごとに使い回すので、コンポーネント化して `totalPages` を受け取り、表示するページ番号は子側で自前生成するのがラクです。「現在ページの前後 `range` 件だけ番号を出す」ロジックを子に持たせています。
ts
// PartsPager.vue の <script setup>
const props = defineProps({
totalPages: {
required: true, // ※ “require” ではなく “required” が正しい
type: Number,
},
range: {
default: 2, // 現在ページの前後に出す番号数
type: Number,
},
});// currentNum: 現在のページ番号(route のクエリ等から取得した値)
const pages = [];
for (let i = 1; i < props.totalPages + 1; i++) {
if (i < currentNum – props.range) continue; // 範囲より前は飛ばす
if (i > currentNum + props.range) continue; // 範囲より後ろは飛ばす
pages.push(i);
}
この `pages` 配列を `v-for` で回せば、「… 4 5 [6] 7 8 …」のような現在ページ中心のページャーになります。総ページ数さえ正しく数値で渡せれば、表示ロジックは子側で完結するので再利用が効きます。
教訓: REST API の「件数・ページ数」はボディではなくヘッダにいることがある。ヘッダを読むなら `$fetch.raw`、そして**ヘッダ値は文字列なので必ず数値変換。
ハマりポイント③:ScrollTrigger が SSR で誤発火する
スクロール連動のアニメーション(フッター付近の要素に `is-scroll` クラスを付与)を GSAP ScrollTriggerで組んでいたのですが、ページ読み込み直後にフッター(`.c_contact`)が「画面内」と判定され、クラスが即発火してしまう問題に当たりました。
原因は SSR / ハイドレーションのタイミング**です。サーバー側でレンダリングされたフッターが、ScrollTrigger が正しく初期化される前に「ビューポート内にある」と評価されてしまう。`start` の調整や `nextTick` での遅延を試しましたが、ハイドレーション起因のチラつきは根本的には消えませんでした。
最終的に、ScrollTrigger をやめて Web 標準の `IntersectionObserver` に置き換える**ことで解決しました。
SSR タイミングに左右されず、要素が実際に交差したときだけ発火します。
ts
let observer: IntersectionObserver | null = nullonMounted(() => {
const target = document.querySelector(‘.c_contact’)
if (!target) returnobserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle(‘is-scroll’, entry.isIntersecting)
})
}, { threshold: 0.2 })observer.observe(target)
})onBeforeUnmount(() => {
observer?.disconnect() // ScrollTrigger の st.kill() の代わり
})
ちなみに観測対象のクラス名は、思い込みで `.contact` としていたら実際は `.c_contact` で、DevTools で実DOMを確認して気づきました。**「動かない」ときはまず実際に吐き出された DOM を見る**——①と同じ教訓です。
> **教訓:** SSR 環境のスクロール演出は、ライブラリの初期化タイミングと戦うより `IntersectionObserver` に寄せたほうが素直なことが多い。
ハマりポイント④:OGP / canonical / sitemap の地味な罠
ヘッドレス構成 + SSR ならではの、SEO 周りで踏んだ小さな地雷もまとめておきます。
どれも「気づきにくいが放置すると効く」タイプです。
og:image の二重定義
`app.vue` のデフォルト OGP と、記事詳細ページの上書きが**両方出力されて重複**していました。詳細ページ側の `useHead` の meta 配列を `useSeoMeta` に寄せることで解消。OGP は「デフォルト1セット+ページ上書き」の責務を明確に分けるのが安全です。
canonical はベタ書きでよかった
`runtimeConfig` で環境別にURLを切り替えようとして逆にエラーを踏みました。本番URLが確定しているサイトなら、`useRoute()` + `useHead` の link で**本番ドメインをハードコードするほうが事故が少ない**という割り切りに落ち着きました。
sitemap.xml は「静的 + 動的」を合成する
静的ページ18本と、WordPress から動的取得した `post_news` を**両方** `sitemap.xml` のサーバールートで出力。片方だけだと当然インデックスから漏れます。
Google Search Console の日付フォーマットエラー
sitemap の `lastmod` で GSC が日付不正を出しました。原因は WordPress の `post.modified` にタイムゾーンが付いていなかったこと。**`+09:00` を付与**して解決。地味ですが、これがないと延々と「無効な日付」警告が出続けます。
まとめ:この構成で作るなら先に知っておきたかったこと
実際に Nuxt 3 × WordPress REST API × Vercel × Xサーバーでポートフォリオを組んで、ハマったポイントを実体験ベースで残しました。要点を再掲します。
1. Vercel SSR → 共用サーバーの WordPress API は「動的IPが自動Bot検知で弾かれて」届かないことがある。** 切り分けはブラウザ直叩きが最速、解決は `server/api/` プロキシが無料かつ確実。
2. ページネーションの総ページ数はボディではなく HTTP ヘッダ(`x-wp-totalpages`)にある。 `$fetch.raw` で取得し、文字列なので必ず `Number()` で数値変換。`$fetch.raw` を叩く箇所はプロキシ書き換えも忘れずに(`grep` で一括洗い出し)。
3. SSR のスクロール演出は `IntersectionObserver` に寄せる。ライブラリの初期化タイミングと戦わない。
4. OGP / canonical / sitemap は「重複」と「日付フォーマット」が地雷。 実DOM と GSC を見て潰す。
共通して効いたのは、「動かない」を感覚で詰めず、どの経路・どの実DOMで切れているかを毎回確定させてから手を動かすこと。サーバーレス × 共用サーバーという、相性に癖のある組み合わせほどこの基本が効きました。同じ構成で詰まっている人の時間短縮になれば幸いです。
※本記事は筆者が実際に構築・運用した際の記録に基づいています。各サービスの仕様・料金は変更される可能性があるため、導入時は公式ドキュメントで最新情報をご確認ください。
Writer
Designer




1986年愛知県生まれ。制作会社での3年間のキャリアを経て、2014年に「ダブダブダブ」として独立。 デザイナーとしての感性を軸に、戦略立案からマーケティング、そして最終的な実装までを一貫して手がけています。 私の強みは、上流工程の「戦略」…