2026年6月22日

2026年6月22日

WordPress×Next.jsのHeadless構成でISR(静的生成と再検証)を設定する方法

WordPress をヘッドレス CMS にして Next.js で表示する構成では、ISR(Incremental Static Regeneration)を使うと、ページを静的に配信しながら記事の更新も反映できます。設定の要点は「一定時間ごとの再検証」と「WordPress で公開・更新したときに Next.js へ再検証を依頼するオンデマンド再検証」の2つを組み合わせることです。この記事では App Router を前提に、データ取得、静的生成、WordPress 側からの再検証の呼び出し、つまずきやすい点の確認方法までを説明します。

ISR の仕組みを先に押さえる

ISR では、ページを一度生成したらキャッシュから配信し、指定した条件で作り直します。作り直しのきっかけは2種類あります。

  • 時間ベース:revalidate = 3600 のように秒数を指定すると、その時間が過ぎたあとの最初のアクセスで、古いページを返しつつ裏で新しいページを生成する。次のアクセスから新しいページになる
  • オンデマンド:revalidatePath() や revalidateTag() を呼んだ時点で、対象のキャッシュを無効にする

時間ベースだけだと、記事を直してから最大で指定時間ぶん古い内容が表示されます。逆にオンデマンドだけだと、通知が何らかの理由で届かなかったときに古いページが残り続けます。そのため、長めの時間ベース(例えば1時間)を保険として置き、更新はオンデマンドで即時に反映する組み合わせが運用しやすい形です。

ISR はサーバーで動く Next.js が前提です。output: 'export' による完全な静的書き出しでは使えません。Vercel のほか、next start で動かす Node.js サーバーでも動きますが、複数台で動かす場合はキャッシュの共有方法(キャッシュハンドラー)の検討が必要です。

Next.js はバージョンごとにキャッシュの既定値と書き方が変わってきました。Next.js 15 では fetch が既定でキャッシュされなくなり、params が Promise になりました。Next.js 16 で導入された Cache Components を有効にした場合は、'use cache' と cacheLife・cacheTag を使う書き方になります。この記事のコードは Cache Components を有効にしていない App Router を想定しています。利用中のバージョンの公式ドキュメントと照らし合わせてください(2026年10月現在)。

WordPress 側の準備

データ取得には、WordPress 標準の REST API(/wp-json/wp/v2/posts)を使います。WPGraphQL でも同じ考え方で組めますが、ここでは追加プラグインなしで始められる REST API で説明します。

準備として確認しておきたいのは次の点です。

  • パーマリンク設定:「基本」以外にしておくと /wp-json/ の URL で REST API にアクセスできる
  • スラッグ:日本語のスラッグは URL エンコードされた文字列になり、Next.js 側のパスやキャッシュの扱いが分かりにくくなる。ヘッドレス構成では英数字とハイフンのスラッグにそろえると、トラブルを減らせる
  • 公開範囲:REST API で取得できるのは公開済みの投稿だけ。下書きのプレビューは別の仕組みが必要(後述)

ブラウザで https://cms.example.com/wp-json/wp/v2/posts?_fields=id,slug,title を開き、JSON が返ることを確認しておきます。セキュリティ系のプラグインで REST API を制限している場合は、ここで弾かれていないかも見てください。

Next.js で記事ページを静的生成する

記事ページ app/blog/[slug]/page.tsx の例です。環境変数 WP_API_URL に https://cms.example.com/wp-json/wp/v2 のような値を設定しておきます。

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

const WP_API = process.env.WP_API_URL;

export const revalidate = 3600; // 保険としての時間ベース再検証(秒)

type WPPost = {
  id: number;
  slug: string;
  title: { rendered: string };
  content: { rendered: string };
};

async function getPost(slug: string): Promise<WPPost | null> {
  const res = await fetch(
    `${WP_API}/posts?slug=${encodeURIComponent(slug)}&_fields=id,slug,title,content`,
    { next: { revalidate: 3600 } }
  );
  if (!res.ok) {
    throw new Error(`WordPress API error: ${res.status}`);
  }
  const posts: WPPost[] = await res.json();
  return posts[0] ?? null;
}

export async function generateStaticParams() {
  const res = await fetch(`${WP_API}/posts?per_page=50&_fields=slug`);
  if (!res.ok) return [];
  const posts: { slug: string }[] = await res.json();
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}

コードのポイントを説明します。

generateStaticParams() はビルド時に生成するページの一覧を返します。ここでは新しい50件だけをビルド時に作り、それ以外の記事は最初にアクセスされたときに生成されます。これは dynamicParams が既定で true のためで、記事数が多いサイトでもビルド時間を抑えられます。

getPost() で res.ok でないときに例外を投げているのは意図的です。再検証の途中で WordPress がエラーを返した場合、Next.js は直前に生成済みのページを配信し続けます。エラー時に空のページを返す実装にしてしまうと、WordPress の一時的な障害で記事が消えたように見えるため、失敗は失敗として扱います。

content.rendered は WordPress が整形した HTML なので dangerouslySetInnerHTML で表示しています。投稿者が信頼できる編集者に限られていれば一般的な方法ですが、外部の寄稿者が多いサイトでは、表示前に HTML サニタイザーを通すことも検討してください。

一覧ページ app/blog/page.tsx も同じように revalidate を指定して作ります。

WordPress から再検証を依頼する

次に、記事を公開・更新・非公開にしたとき、WordPress から Next.js に再検証を依頼する仕組みを作ります。

Next.js 側:再検証用のルートハンドラー

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  return ab.length === bb.length && timingSafeEqual(ab, bb);
}

export async function POST(req: NextRequest) {
  const expected = process.env.REVALIDATE_SECRET ?? '';
  const received = req.headers.get('x-revalidate-secret') ?? '';
  if (!expected || !safeEqual(received, expected)) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  const body = await req.json().catch(() => ({}));
  const slug = typeof body.slug === 'string' && /^[a-z0-9-]+$/.test(body.slug) ? body.slug : null;

  revalidatePath('/blog');
  if (slug) {
    revalidatePath(`/blog/${slug}`);
  }
  return NextResponse.json({ revalidated: true, slug });
}

秘密の文字列は環境変数 REVALIDATE_SECRET に置き、コードやリポジトリには含めません。比較に timingSafeEqual を使っているのは、文字列比較の時間差から秘密を推測されにくくするためです。受け取ったスラッグは形式を検証してから使います。

WordPress 側:公開状態の変化を通知する

WordPress 側は transition_post_status フックで投稿の状態変化を受け取り、Next.js に通知します。専用のプラグインか mu-plugin に置き、秘密の文字列は wp-config.php に定数として定義します(define( 'NEXT_REVALIDATE_SECRET', 'your-random-secret' ); のような形で、値は十分に長いランダム文字列にします)。

<?php
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( 'post' !== $post->post_type ) {
		return;
	}
	// 公開中の記事に関わる変化(公開・更新・非公開化・ゴミ箱)だけを通知する
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return;
	}
	if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
		return;
	}
	if ( ! defined( 'NEXT_REVALIDATE_SECRET' ) ) {
		return;
	}

	// ゴミ箱に入れるとスラッグの末尾に __trashed が付くため取り除く
	$slug = preg_replace( '/__trashed$/', '', $post->post_name );

	wp_remote_post( 'https://www.example.com/api/revalidate', [
		'headers'  => [
			'Content-Type'        => 'application/json',
			'x-revalidate-secret' => NEXT_REVALIDATE_SECRET,
		],
		'body'     => wp_json_encode( [ 'slug' => $slug ] ),
		'timeout'  => 5,
		'blocking' => false,
	] );
}, 10, 3 );

'blocking' => false にしているのは、Next.js 側の応答を待って管理画面の保存が遅くなるのを避けるためです。その代わり、通知の成否は WordPress 側では分かりません。動作確認の段階では一時的に blocking を true にしてレスポンスコードを確認するか、Next.js 側のログで受信を確かめてください。予約投稿は公開時刻に状態が変わるのでこのフックで拾えますが、予約投稿の公開は WP-Cron に依存するため、アクセスの少ないサイトでは公開が遅れることがあります。

動作確認とつまずきやすい点

確認は次の順で行うと、どこで止まっているかを切り分けやすくなります。

  1. curl で再検証ルートに正しい秘密を付けて POST し、revalidated: true が返るか。秘密を変えると 401 になるか
  2. WordPress で記事のタイトルを変えて更新し、Next.js 側のページをリロードして反映されるか
  3. 反映されない場合は、Next.js のログに再検証のリクエストが届いているかを見る。届いていなければ WordPress からの送信(URL、ファイアウォール、blocking の設定)を疑う

開発サーバー(next dev)ではキャッシュの挙動が本番と異なるため、ISR の確認は next build と next start で行います。

下書きのプレビューが必要な場合は、Next.js の Draft Mode を使い、認証付きで WordPress から下書きを取得する仕組みを別に用意します。この部分は認証情報をフロントエンドに出さない設計が重要になるため、静的生成とは分けて考えてください。

まとめ

  • ISR は「長めの時間ベース再検証」を保険に、「公開・更新時のオンデマンド再検証」で即時反映する組み合わせが運用しやすい
  • ISR はサーバーで動く Next.js が前提。静的書き出しでは使えず、複数台構成ではキャッシュ共有の検討が必要
  • データ取得でエラーが出たら例外を投げ、直前のページを配信し続けさせる
  • WordPress 側は transition_post_status で公開状態の変化を拾い、秘密の文字列付きで再検証ルートを呼ぶ
  • Next.js はバージョンでキャッシュの既定値と書き方が変わるため、利用中のバージョンの公式ドキュメントで確認する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲