Next.js×WordPress ヘッドレスCMS構築ガイド
2026-09-15T14:45:05執筆: 秋元 英輔

Next.js×WordPress ヘッドレスCMS構築ガイド

このサイト(andcre.com)は、WordPress を編集専用にして、表示は Next.js で書き出した静的ファイルを Cloudflare から配信しています。訪問者が触るのは HTML と CSS と画像、それにフォームとチャット用の API だけで、WordPress には直接アクセスできません。この記事では、その構成を実際のコードを切り出しながら順に説明します。読み終えると、WordPress 側に何を入れるか、Next.js 側で何を書くか、公開までにどこで時間がかかるか、そして私が実際に踏んだ落とし穴が分かります。構成の向き不向きや費用の考え方はサービスページにまとめてあるので、この記事は「どう作るか」に絞ります。

全体像: 編集は WordPress、生成は Next.js、配信は Cloudflare

構成は 4 つの層に分かれます。編集者は WordPress の管理画面で記事や制作実績を書きます。Next.js はビルド時に WPGraphQL 経由でそのデータを取り込み、全ページを HTML として書き出します。書き出したファイルは Cloudflare Pages に置かれ、CDN から配信されます。お問い合わせフォームや AI チャットのように、訪問者の操作に応じてサーバー側の処理が要る機能は、同じ Cloudflare 上の Workers が受け持ちます。

編集者 ──▶ WordPress(api.andcre.com)
        │ WPGraphQL / REST
        ▼ ビルド時に取得
      Next.js 15(output: 'export')──▶ dist/ の HTML・CSS・画像
                         │
                         ▼
訪問者 ◀── Cloudflare Pages(CDN)+ _worker.js(/api/contact, /api/chat …)
                         │
                         └──▶ WordPress(CF7 の REST など、必要なときだけ)

ここで効いているのは、WordPress が「訪問者の閲覧経路」から外れていることです。WordPress のサーバーが遅くても、あるいは一時的に止まっていても、配信済みの HTML はそのまま表示され続けます。各層の技術と役割の一覧はサービスページの「構成要素と役割」に表で載せています。

WordPress 側の準備: WPGraphQL とカスタム投稿の公開

WordPress 側に入れたのは WPGraphQL(と Offset Pagination の拡張)、Contact Form 7、それに自作のテーマの functions.php です。WPGraphQL は WordPress のデータを GraphQL で問い合わせられるようにするプラグインで、https://api.andcre.com/graphql というエンドポイントが生えます。通常の投稿はプラグインを入れるだけで取得できますが、制作実績のようなカスタム投稿タイプは、登録時に GraphQL への公開を明示します。

// functions.php — 制作実績(portfolio)を WPGraphQL に公開する(抜粋)
register_post_type( 'portfolio', [
  'public'       => true,
  'show_in_rest'    => true,  // ブロックエディタ用
  'show_in_graphql'   => true,  // WPGraphQL 用
  'graphql_single_name' => 'portfolio',
  'graphql_plural_name' => 'portfolios',
  'rewrite'       => [ 'slug' => 'portfolio' ],
  'has_archive'     => true,
  'supports'      => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
] );

graphql_single_namegraphql_plural_name が、あとで Next.js 側のクエリに出てくる portfolio / portfolios というフィールド名になります。同じ要領で、AI チャットが参照する「RAG コンテキスト」という投稿タイプも登録してあります。編集者から見ると、どれも管理画面の左メニューに並ぶ普通の投稿タイプで、ヘッドレスかどうかを意識する場面はありません。

テーマ自体は表示に使いません。そのためテンプレートファイルは空に近い状態になります。functions.php にカスタム投稿の登録と GraphQL の設定を書いておく場所として残しています。

Next.js 側: 通信を 1 か所にまとめる

Next.js 側は Repository → Service → Page の 3 層に分けています。Repository は WordPress との通信だけ、Service は取得した JSON を TypeScript の型に変換する処理だけ、Page はそれを表示するだけ、という分担です。通信を 1 か所にまとめておくと、タイムアウトやエラーの扱いを全ページで揃えられます。

// src/repositories/Repository.ts(抜粋)
import axios, { AxiosError } from "axios";
import { getGraphqlEndpoint } from "@/config/wordpress";
import { recordRepositoryError } from "@/utils/buildGuard";

export class RepositoryError extends Error {
 constructor(message: string, public readonly statusCode?: number) {
  super(message);
  this.name = "RepositoryError";
 }
}

const repository = axios.create({
 baseURL: getGraphqlEndpoint(),  // 環境変数 NEXT_PUBLIC_WP_ENDPOINT
 headers: { "Content-Type": "application/json" },
 timeout: 30000,
});

// GraphQL は HTTP 200 で errors を返すことがあるので、ここで例外に変換する
repository.interceptors.response.use(
 (response) => {
  const errors = response.data?.errors;
  if (errors?.length) {
   const error = new RepositoryError(`GraphQL Error: ${errors.map((e) => e.message).join(", ")}`);
   recordRepositoryError(error);
   throw error;
  }
  return response;
 },
 (error: AxiosError) => {
  const wrapped = error.response
   ? new RepositoryError(`HTTP Error: ${error.response.status}`, error.response.status)
   : new RepositoryError("Network Error: No response received");
  recordRepositoryError(wrapped);
  throw wrapped;
 }
);

const Repository = (query: string, { variables }: Record<string, unknown> = {}) => ({
 getWp: () => repository.post("/", { query, variables }),
});

export default Repository;

GraphQL の面倒な点は、クエリに誤りがあっても HTTP のステータスは 200 で、本文の errors に理由が入って返ってくることです。インターセプターでそれを例外に変換しておくと、呼び出し側は「成功したら data がある」という前提で書けます。recordRepositoryError は次の節で説明するビルド時の安全装置に使います。

URL 一覧を列挙して、1 ページずつ書き出す

静的エクスポートでは、ビルド時に「どの URL を作るか」を Next.js に教える必要があります。App Router では generateStaticParams がその役目で、WordPress から全記事のスラッグを取ってきて返します。

// src/app/pages/post/[slug]/page.tsx(抜粋)
import PostService from "@/services/PostService";
import { assertStaticParams } from "@/utils/buildGuard";
import { sanitizeHtml } from "@/utils/sanitize";

export const dynamic = "force-static";

export async function generateStaticParams() {
 const paths = await PostService.getAllSlugList();
 // WordPress に到達できず 0 件のときは、原因つきでビルドを止める
 return assertStaticParams(
  "/pages/post/[slug]",
  paths.map((path) => ({ slug: path.params.slug }))
 );
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
 const { slug } = await params;
 const post = await PostService.getOne({ slug });
 // WordPress の本文 HTML は DOMPurify に通してから描画する
 return <article dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.content) }} />;
}

PostService.getAllSlugList の裏で投げているクエリは次のものです。first: 10000 は「全件」の代わりで、WPGraphQL の既定の上限(100 件)を超えて取るには Offset Pagination の拡張か、上限を引き上げる設定が要ります。

query AllSlugs {
 posts(first: 10000) {
  edges {
   node {
    slug
    modified
   }
  }
 }
}

modified を一緒に取っているのは、サイトマップの lastmod に使うためです。記事の lastmod は WordPress の更新日、固定ページの lastmod はそのページのソースファイルを最後にコミットした日を Git から取って入れています。ビルド日時を lastmod にすると、デプロイのたびに全ページが「更新された」ことになり、検索エンジンが日付を信用しなくなるためです。

assertStaticParams は、私が実際に困ってから足した安全装置です。Service 層は通信の失敗をログに出して空配列を返す作りにしていたのですが、本番ビルドではそのログが出ません。すると WordPress に到達できなかったとき、Next.js は「generateStaticParams が無い」という別のエラーで落ちるか、記事ページが静かに欠けたまま「成功した」ビルドになるか、どちらかになります。0 件を検出したら、インターセプターが記録しておいた直近の通信エラー(ステータスコードとメッセージ)を添えてビルドを止めるようにしました。データの流れ全体はサービスページの「ビルド時のデータの流れ」に 4 段階で図にしてあります。

WordPress から来る本文 HTML は、描画の直前に DOMPurify(isomorphic-dompurify)に通してから dangerouslySetInnerHTML に渡しています。許可するタグは p a ul ol li h2h6 pre code など 21 種類、属性は href target rel class id の 5 つに絞っています。編集者は信頼できても、プラグインや埋め込みが想定外の HTML を吐くことはあるので、境界で一度ふるいにかけておく方が安全です。

静的エクスポートで気をつけること

next.config.mjs で静的エクスポートを有効にします。設定は少ないですが、2 つほど Cloudflare Pages 向けの決まりがあります。

// next.config.mjs(抜粋)
const isProd = process.env.NODE_ENV === "production";

const nextConfig = {
 ...(isProd ? { output: "export", distDir: "dist" } : {}),
 trailingSlash: true,     // /service/web/ の形に統一(Pages の配信ルールと合わせる)
 images: {
  unoptimized: true,     // next/image の最適化サーバーは使えない(静的配信のため)
 },
 serverExternalPackages: ["isomorphic-dompurify", "jsdom"],
};

export default nextConfig;

images.unoptimized を有効にすると next/image はただの <img> になり、リサイズや WebP 変換はしてくれません。代わりに、ビルドの先頭で sharp を使う自前のスクリプトを走らせ、public/ の JPEG と PNG から元サイズの WebP と 640 / 1024 / 1600 幅の縮小版を作り、幅ごとの srcset<picture> に出しています。3000 px 四方の元画像を 400 px のカードに配信していた頃と比べて、サービスカード 1 枚の転送量は 309 KB から 29 KB(640 幅)になりました。

output: 'export' を本番ビルドだけに限定しているのは、開発サーバー(next dev)では通常の挙動にしておきたいからです。dist/ にはページごとに index.html が書き出され、/service/web/index.html のように URL の階層がそのままディレクトリになります。

動的な機能はエッジの Worker に寄せる

静的ファイルだけでは、フォームの送信や AI チャットのようにサーバー側の処理が要る機能は作れません。そこで、Cloudflare Pages に同梱できる _worker.js で 5 本の API を用意しています。お問い合わせは、その中でいちばん単純なものです。

// public/_worker.js — /api/contact(抜粋)
async function handleContactAPI(request, env) {
 if (request.method !== "POST") return errorResponse("Method not allowed", 405);

 const data = await request.json();
 // Contact Form 7 の REST API にそのまま中継する。フォーム ID とシークレットは env に置く
 const endpoint = `${env.WORDPRESS_URL}/wp-json/contact-form-7/v1/contact-forms/${env.CF7_FORM_ID}/feedback`;

 const formData = new FormData();
 for (const [key, value] of Object.entries(data)) formData.append(key, value);

 const wpResponse = await fetch(endpoint, { method: "POST", body: formData, headers: { Accept: "application/json" } });
 const result = await wpResponse.json();

 if (result.status === "mail_sent") {
  return jsonResponse({ status: "success", message: "お問い合わせを受け付けました。" });
 }
 return errorResponse(result.message || "Failed to submit form", 500);
}

ブラウザから見えるのは https://andcre.com/api/contact だけで、WordPress の URL もフォーム ID も Worker の環境変数にあります。メール送信そのものは Contact Form 7 に任せているので、WordPress 側の設定画面でメールの宛先や文面を変えられるという、通常の WordPress サイトと同じ運用が残ります。

AI チャット(/api/chat)はもう少し込み入っていて、初回だけ Cloudflare Turnstile でボットを弾き、WordPress に登録した RAG コンテキストを 5 分キャッシュしつつ取ってきて、Workers AI のモデルに渡しています。API の一覧と役割はサービスページの「動的な機能はエッジで処理」にあります。

公開までの流れと、反映にかかる時間

記事を WordPress で公開しても、サイトには自動では反映されません。ビルドとデプロイが間に挟まるからです。流れは次のとおりです。

# .github/workflows/deploy.yml(要点のみ)
on:
 push:
  branches: [production]
jobs:
 deploy:
  steps:
   - uses: actions/checkout@v4
    with:
     fetch-depth: 0      # サイトマップの lastmod が git log を見るため、浅いクローンにしない
   - uses: actions/setup-node@v4
    with:
     node-version-file: .nvmrc # Node 22
   - run: npm ci
   - run: npm run build     # WebP 生成 → next build → CSS リンクの後処理
   - uses: cloudflare/wrangler-action@v3  # dist/ を Cloudflare Pages へ
   - run: node scripts/indexnow-submit.mjs # 更新 URL を IndexNow に通知

production ブランチにマージすると GitHub Actions がビルドしてデプロイします。所要時間は数分です。記事だけを更新したい場合もこの流れを通るため、「分単位で即時に反映したい」という要件には向きません。逆に、コードの変更と記事の更新が同じ経路を通るので、デプロイのたびにテスト(2026-09-12 時点で Vitest 475 件)と SEO の回帰チェック(同 66 項目)が走り、タイトルの文字数や内部リンクの本数、構造化データの有無が崩れていないかを機械的に確認できます。

更新をフックにして自動でビルドを起動する方法(WordPress からの Webhook で GitHub Actions を起動する)もあり、更新頻度が高いサイトではその方が楽です。andcre.com はコードの変更と記事の更新をまとめて出す運用なので、手動のマージを起点にしています。

実際に踏んだ落とし穴

ここからは、構成そのものより運用で効いてくる話です。どれもこのサイトで実際に起きたことで、コードやドキュメントに再発防止を書き残してあります。

CI のビルドだけが失敗し、ローカルでは通る、という状態が続いたことがあります。原因は 2 つ重なっていて、1 つは GitHub Actions の Node が 18 で、依存パッケージの一部(Tailwind のネイティブモジュール)が Node 20 以上を要求していたこと。もう 1 つは、GitHub Actions のランナーの IP からの WordPress へのリクエストが、WordPress 側の WAF(Cloudflare)に 403 で弾かれていたことです。前者は .nvmrc で Node 22 に固定して解決し、後者はランナーの IP 帯を WAF の許可リストに入れる作業になります。ビルド時に外部の CMS へアクセスする構成では、「ビルド環境から CMS に届くか」を最初に確かめておくべきでした。

Next.js の metadataopenGraph をページ側に書くと、layout.tsx で設定した og:image ごと置き換わって消えます。深いマージはされません。対策として ogImages() というヘルパーを各ページで必ず併記するルールにし、回帰チェックで og:image の有無を見ています。

スムーススクロールのライブラリ(Lenis)を html 要素に対して動かしていると、next/link の遷移と干渉して、ページ遷移がフルリロードになることがありました。クリック時に Lenis を止めてから遷移する CustomLink を作り、内部リンクはすべてそれを使うことにしています。ヘッドレス構成に固有の話ではありませんが、表示側を自分で組む以上、こうしたライブラリ同士の相性は自分で面倒を見ることになります。

SEO の観点では、URL 構造を変えないことが移行時の最大の注意点です。記事は /pages/post/<slug>/、制作実績は /portfolio/<slug>/ に置き、trailingSlash: true で末尾スラッシュに統一しています。移行前の URL が違う場合は、Cloudflare Pages の _redirects で 301 を返します。

この構成にすると何が変わるか

編集者の作業は変わりません。WordPress の管理画面で記事を書き、公開ボタンを押すところまでは同じです。変わるのは公開後で、サイトに出るまでにビルドとデプロイの数分が入ります。

表示側は TypeScript のコードになるので、デザインや機能の変更はコードの変更として扱い、WordPress のテーマやプラグインは触りません。テストと CI で回帰を防げる反面、WordPress のプラグインで見た目や機能を足す使い方はできません。

配信は静的ファイルなので、WordPress のサーバー性能や稼働状況が表示速度に影響しません。トップページの実測は PageSpeed Insights(desktop)で LCP 2.0 秒、FCP 0.5 秒です(2026-09-12、lab 値)。数字はサイトの内容と画像次第で変わるので、構成を変えれば必ず速くなるという意味ではありません。

向き不向きの整理と費用の考え方はサービスページにまとめました。5 ページ程度の一般的なコーポレートサイトであれば、通常の WordPress 制作の方が開発量が少なく済みます。ホームページ制作の進め方と見比べて、どちらが合うかを判断する材料にしてください。同じ構成で作った美容室サイトの制作実績も公開しています。

まとめ

WordPress を編集専用にし、Next.js の静的エクスポートで HTML を書き出し、Cloudflare Pages と Workers で配信と動的処理を分ける。この構成の意味は、部品の数ではなく、編集のしやすさを保ったまま表示の速さと落ちにくさだけを別の仕組みに移せる点にあります。裏を返せば、更新が即時に反映される必要がある場合や、表示側をプラグインで組み立てたい場合に、この構成を選ぶ理由はありません。

自社サイトに合うかどうか判断がつかない場合は、現在の WordPress の使い方(更新頻度、使っているプラグイン、編集者の人数)を教えていただければ、ヘッドレス構成にする場合としない場合の両方でお見積りします。お問い合わせからどうぞ。

関連記事

Informationの記事一覧を見る →