2026年9月26日

2026年9月26日

WordPressとNext.jsを連携させる方法:App Routerで記事を表示し、更新を即時反映する

WordPress の REST API から記事を取得し、Next.js の App Router で一覧ページと記事ページを作る手順を、実際に動かせるコードで説明します。後半では、WordPress で記事を更新したときに Next.js 側のキャッシュを作り直す仕組み(オンデマンド再検証)と、下書きプレビューの考え方まで扱います。ヘッドレス化するかどうかの判断や WordPress 側の API 整備は別記事の範囲とし、ここでは「Next.js 側で何を書けばつながるか」に絞ります。

前提と全体の流れ

この記事のコードは、Next.js 15 以降の App Router と TypeScript を前提にしています(2026年10月現在)。Next.js はメジャーバージョンごとにキャッシュの既定動作や一部の API の書き方を変えてきたため、使っているバージョンの公式ドキュメントと照らし合わせながら読んでください。

構成は次のとおりです。

  1. WordPress(例:https://cms.example.com)は記事を保存し、REST API で返す。
  2. Next.js(例:https://www.example.com)はサーバー側で API を呼び、HTML を生成して配信する。取得結果は一定時間キャッシュする。
  3. WordPress で記事を公開・更新したら、Webhook で Next.js に知らせ、該当ページのキャッシュを作り直す。

データ取得はサーバーコンポーネントの中で行うため、ブラウザから WordPress に直接アクセスするわけではありません。公開記事を読むだけなら、CORS の設定は通常不要です。

環境変数を用意する

接続先や秘密値はコードに書かず、.env.local に置きます。このファイルは Git に含めないでください。

WP_API_URL=https://cms.example.com/wp-json/wp/v2
REVALIDATE_SECRET=your-random-secret
WP_APP_USER=preview-bot
WP_APP_PASSWORD=your-application-password

NEXT_PUBLIC_ で始まる変数はブラウザに配信される JavaScript に埋め込まれます。秘密値には付けないでください。本番環境では、ホスティング先の環境変数の設定画面に同じ値を登録します。

取得用の関数をまとめる

// lib/wordpress.ts
const WP_API = process.env.WP_API_URL;

export type WPPost = {
  id: number;
  slug: string;
  date: string;
  title: { rendered: string };
  excerpt: { rendered: string };
  content?: { rendered: string };
};

async function wpFetch<T>(path: string): Promise<T> {
  const res = await fetch(`${WP_API}${path}`, {
    next: { revalidate: 300 }, // 5分間キャッシュ。更新はWebhookで即時反映する
  });
  if (!res.ok) {
    throw new Error(`WordPress API error: ${res.status} ${path}`);
  }
  return res.json() as Promise<T>;
}

export function getPosts() {
  return wpFetch<WPPost[]>('/posts?per_page=20&_fields=id,slug,date,title,excerpt');
}

export async function getPostBySlug(slug: string) {
  const posts = await wpFetch<WPPost[]>(
    `/posts?slug=${encodeURIComponent(slug)}&_fields=id,slug,date,title,excerpt,content`
  );
  return posts[0] ?? null;
}

_fields で必要な項目だけを指定すると、応答が小さくなり表示も速くなります。slug で検索した結果は配列で返るため、先頭の要素を取り出しています。

revalidate は「この秒数が過ぎた後のアクセスで裏側から取り直す」という指定です。値を短くすると WordPress への負荷が増えるため、後述の Webhook と組み合わせて長めにしておくのが扱いやすい構成です。

一覧ページと記事ページ

// app/blog/page.tsx
import Link from 'next/link';
import { getPosts } from '@/lib/wordpress';

export default async function BlogPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>
            <span dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
          </Link>
        </li>
      ))}
    </ul>
  );
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug, getPosts } from '@/lib/wordpress';

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post || !post.content) notFound();

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

Next.js 15 以降では params が Promise になったため、await してから使います。14 以前のコードを流用すると型エラーになる点に注意してください。

つまずきやすい点を挙げます。

  • タイトルの文字参照:title.rendered には &#8217; のような文字参照が含まれることがあり、そのまま文字列として出すと記号が崩れて見えます。上の例では HTML として描画しています。
  • 本文の HTML の扱い:content.rendered は WordPress が整形した HTML です。投稿できるのが信頼できる編集者だけなら上の書き方で動きますが、投稿者が多い、外部から取り込んだ記事があるといった場合は、sanitize-html などのライブラリで許可するタグを絞ってから描画してください。
  • ブロックのスタイル:本文の HTML にはブロックエディター用のクラス名が付きますが、そのための CSS は Next.js 側にはありません。必要なスタイルを自前で用意します。
  • 日本語スラッグ:日本語のスラッグは URL エンコードされた形で返ります。ルーティングで二重にエンコードされるなどの混乱を避けるため、記事のスラッグは英数字にしておくのが無難です。

ページの <title> や説明文は、同じファイルで generateMetadata を定義して出力します。WordPress の SEO プラグインが API に出すメタ情報を使う場合は、そのプラグインのドキュメントで項目名を確認してください。

画像を next/image で扱う

WordPress のメディアを next/image で表示するには、配信元を設定ファイルで許可します。

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cms.example.com',
        pathname: '/wp-content/uploads/**',
      },
    ],
  },
};

export default nextConfig;

許可する範囲は、アップロードフォルダに限定しておくと安全です。本文 HTML の中の <img> はこの設定の対象外で、WordPress のサーバーから直接読み込まれます。

更新を即時反映する(オンデマンド再検証)

Next.js 側に、秘密値を照合してからキャッシュを作り直すルートハンドラーを用意します。

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

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

  const body = await req.json().catch(() => ({}));
  const slugs: string[] = Array.isArray(body.slugs)
    ? body.slugs.filter((s: unknown) => typeof s === 'string' && /^[a-z0-9-]+$/.test(s))
    : [];

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

WordPress 側は、投稿の保存が完了した後に動く wp_after_insert_post フックで通知します。mu-plugin(wp-content/mu-plugins/ 内の PHP ファイル)に置くと、テーマの切り替えやプラグインの無効化で止まりません。

<?php
// wp-content/mu-plugins/next-revalidate.php
add_action( 'wp_after_insert_post', function ( $post_id, $post, $update, $post_before ) {
	if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
		return;
	}
	if ( 'post' !== $post->post_type ) {
		return;
	}
	$was_public = $post_before && 'publish' === $post_before->post_status;
	if ( 'publish' !== $post->post_status && ! $was_public ) {
		return; // 公開にも非公開化にも関係しない保存は通知しない
	}
	if ( ! defined( 'NEXT_REVALIDATE_URL' ) || ! defined( 'NEXT_REVALIDATE_SECRET' ) ) {
		return;
	}

	$slugs = array( $post->post_name );
	if ( $post_before && $post_before->post_name && $post_before->post_name !== $post->post_name ) {
		$slugs[] = $post_before->post_name; // スラッグ変更・ゴミ箱移動時は旧URLも作り直す
	}

	wp_remote_post(
		NEXT_REVALIDATE_URL,
		array(
			'headers'  => array(
				'Content-Type'        => 'application/json',
				'x-revalidate-secret' => NEXT_REVALIDATE_SECRET,
			),
			'body'     => wp_json_encode( array( 'slugs' => array_values( array_filter( $slugs ) ) ) ),
			'timeout'  => 5,
			'blocking' => false,
		)
	);
}, 10, 4 );

wp-config.php に define( 'NEXT_REVALIDATE_URL', 'https://www.example.com/api/revalidate' ); と define( 'NEXT_REVALIDATE_SECRET', 'your-random-secret' ); を追記し、Next.js 側の REVALIDATE_SECRET と同じ値にします。'blocking' => false にしているのは、Next.js 側の応答を待たずに保存を終わらせ、編集画面が遅くならないようにするためです。そのぶん失敗に気づきにくいので、動作確認の段階では一時的に true にして応答コードを確かめると切り分けが楽になります。

wp-config.php を編集する前にはバックアップを取り、ステージング環境で通知が届くことを確認してから本番に入れてください。

下書きプレビューの考え方

公開前の記事は認証なしでは REST API から取得できません。Next.js の Draft Mode を使い、次の流れで実装するのが一般的です。

  1. WordPress の preview_post_link フィルターで、プレビューボタンの行き先を Next.js のプレビュー用ルートに変える。
  2. Next.js のルートハンドラーで秘密値を照合し、Draft Mode を有効にして記事ページへ転送する。
  3. Draft Mode のときだけ、サーバー側で WP_APP_USER と WP_APP_PASSWORD(WordPress のアプリケーションパスワード)を Basic 認証で送り、キャッシュせずに下書きを取得する。

アプリケーションパスワードは専用ユーザーで発行し、サーバー側のコードだけで使います。他の人が書いた下書きを読むには、そのユーザーに編集者相当の権限が要ります。管理者権限は付けず、必要な範囲にとどめてください。Draft Mode の有効化の書き方は Next.js のバージョンで変わっているため、公式ドキュメントのコードを基準にしてください。

まとめ

  • データ取得はサーバーコンポーネントで行い、接続先と秘密値は環境変数に置く。NEXT_PUBLIC_ を秘密値に付けない。
  • _fields で応答を絞り、revalidate は長めにして、更新は Webhook とオンデマンド再検証で反映する。
  • Next.js 15 以降は params を await する。タイトルの文字参照、本文 HTML のサニタイズ、ブロックの CSS、日本語スラッグでつまずきやすい。
  • WordPress 側の通知は wp_after_insert_post を使う mu-plugin にまとめ、秘密値は wp-config.php の定数で管理する。
  • プレビューは Draft Mode とアプリケーションパスワードをサーバー側だけで組み合わせる。

お気軽にご相談ください

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