title: >- Архитектура Headless WP + Next.js 16: On-Demand ISR, HMAC Webhooks и Draft Mode description: >- Проектирование отказоустойчивой связки WordPress 7.0+ (Origin CMS) и Next.js 16 App Router в 2026 году: гранулярная инвалидация кэша через revalidateTag и cacheTag, криптографически подписанные вебхуки HMAC-SHA256 на wp_after_insert_post, превью черновиков через draftMode() и защита Origin-сервера. module: rest-api-headless order: 5 difficulty: advanced estimatedTime: 42 мин topics:
Переход к Headless WordPress (Decoupled CMS) разделяет монолит на два независимых контура: Content Origin (WordPress 7.0+ на PHP 8.4+), где работают редакторы, хранятся медиафайлы и исполняются бизнес-правила, и Edge Presentation Layer (Next.js 16 App Router), который отвечает за мгновенный рендеринг, доставку статики с глобальной сети CDN и интерактивные клиентские острова.
Однако наивная реализация Headless-архитектуры почти всегда терпит крах в двух точках:
cache: 'no-store'), каждый визит пользователя на сайт пробивает Next.js насквозь и бьёт в PHP-воркеры WordPress, уничтожая весь смысл разделения. Если же выставить длинный статический кэш (SSG), редакторы нажимают кнопку «Обновить» в админке WordPress и не видят изменений на сайте часами.draft / autosave), так как публичный API не отдаёт неопубликованные записи, а фронтенд не знает о существовании ревизии.В 2026 году эталонным решением первой проблемы является Событийно-ориентированная гранулярная инвалидация (Event-Driven Tag Revalidation): фронтенд кэширует данные бессрочно или на часы с помощью серверных тегов (cacheTag('post-42', 'collection-posts')), а WordPress в момент завершения сохранения (wp_after_insert_post) отправляет асинхронный, криптографически подписанный HMAC-SHA256 вебхук в Route Handler Next.js 16, вызывающий revalidateTag(). Для решения второй проблемы внедряется защищённый контур Next.js 16 draftMode() с одноразовым токеном предпросмотра.
Представьте связку WordPress 7.0+ и Next.js 16 как Центральный монетный двор (WordPress Origin) и Сеть бронированных витрин по всему миру (Next.js Edge Data Cache). Посетители никогда не заходят внутрь монетного двора — они смотрят на мгновенные слепки витрин (TTFB менее 20 мс). Монетный двор вообще не обслуживает читательский трафик напрямую; он лишь рассылает криптографически заверенные телеграммы (HMAC-SHA256 Webhook) витринам: «Экспонат post-42 и каталог category-architecture обновились — пересоберите ровно эти два слепка в фоновом режиме».
[Редактор в WP Admin] ──► Сохраняет пост #42 (хук wp_after_insert_post)
│
▼
[WP 7.0+ Webhook Dispatcher] ──► Вычисляет HMAC-SHA256(payload, WEBHOOK_SECRET)
Отправляет POST /api/revalidate (timeout: 2s)
│
▼
[Next.js 16 Route Handler] ──► Проверяет X-Agentic-Signature-256 (timingSafeEqual)
Вызывает revalidateTag('post:42', 'default')
Вызывает revalidateTag('posts:list', 'default')
│
▼
[Next.js 16 Edge Cache] ──► Мгновенно помечает теги как устаревшие (Stale)
и фоново запрашивает свежий JSON у WP OriginСерверные функции чтения в Next.js 16 маркируют каждый запрос к WP REST/GraphQL гранулярными тегами: post:{slug}, post:{id}, term:{id}.
После того как WordPress записал и сам пост, и все его мета-поля, и таксономии, диспетчер формирует компактный JSON-пакет инвалидации.
Тело вебхука подписывается общим секретом (hash_hmac('sha256', ...)) и отправляется в Next.js заголовком X-Agentic-Signature-256.
Route Handler в Next.js 16 сверяет подпись через crypto.timingSafeEqual и сбрасывает ровно затронутые теги без пересборки всего сайта.
При клике на «Превью» в Gutenberg редактор перенаправляется на /api/draft?postId=42&secret=..., активирующий cookie __prerender_bypass.
Стандартизируйте именование тегов между PHP и TypeScript: post:{id}, post:slug:{slug}, collection:{post_type}, term:{id}, global:settings.
Сгенерируйте 256-битный секрет WP_WEBHOOK_SECRET, пропишите его в wp-config.php и .env.production в Next.js и активируйте проверку подписи через timingSafeEqual.
Переопределите preview_post_link в WordPress, чтобы клик по «Просмотреть» в Gutenberg открывал Next.js в режиме draftMode() и запрашивал ревизию с токеном авторизации.
Добавьте заголовок X-Robots-Tag: noindex на поддомен CMS, перепишите ссылки вложений (wp_get_attachment_url) на CDN-домен и закройте API-эндпоинты за Cloudflare WAF / mTLS.
Почему отправка вебхука ревалидации в Next.js на хуке save_post приводит к тому, что мета-поля ACF или Gutenberg обновляются на фронтенде только со второго сохранения?
В инженерной практике Fullstack Agentic Lab паттерны этого урока являются обязательным фундаментом любого продакшен-внедрения Headless WordPress:
< 300ms от клика в WP до обновления на глобальном CDN) без запуска полного next build.layout:navigation или term:architecture во всех зависимых маршрутах Next.js 16.draftMode()): редактор видит свои несохранённые или находящиеся в статусе draft блоки внутри реального дизайна Next.js 16 до публикации.stale-while-revalidate).Сравним три поколения синхронизации данных между WordPress и фронтендом на Next.js:
Ниже представлен двухсторонний производственный артефакт (WordPress 7.0+ / PHP 8.4+ и Next.js 16 App Router / TypeScript 5.8), реализующий криптографически защищённую шину синхронизации контента и безопасный вход в режим предпросмотра черновиков (draftMode()).
<?php
/**
* ЧАСТЬ 1: WordPress 7.0+ (PHP 8.4+) — Диспетчер HMAC-вебхуков и генератор ссылок Draft Mode.
* Файл: wp-content/mu-plugins/agentic-headless-sync.php
*/
declare(strict_types=1);
namespace AgenticLab\Headless;
use WP_Post;
defined('ABSPATH') || exit;
final readonly class NextJsEdgeSyncDispatcher
{
public function __construct(
private string $nextWebhookUrl,
private string $nextDraftUrl,
private string $webhookSecret,
) {}
public function registerHooks(): void
{
// КРИТИЧНО: используем wp_after_insert_post вместо save_post,
// чтобы все мета-поля и таксономии уже были записаны в БД к моменту запроса от Next.js!
add_action('wp_after_insert_post', $this->dispatchPostRevalidation(...), 20, 4);
add_action('edited_term', $this->dispatchTermRevalidation(...), 20, 3);
add_filter('preview_post_link', $this->rewriteGutenbergPreviewLink(...), 10, 2);
}
/**
* Отправляет подписанный HMAC-SHA256 вебхук при публикации/обновлении записи.
*/
public function dispatchPostRevalidation(
int $postId,
WP_Post $post,
bool $update,
?WP_Post $postBefore
): void {
// Игнорируем автосохранения, ревизии и технические типы записей
if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
return;
}
// Инвалидируем кэш только если пост опубликован сейчас ИЛИ был опубликован до изменения (например, снят с публикации)
$wasPublished = $postBefore instanceof WP_Post && $postBefore->post_status === 'publish';
$isPublished = $post->post_status === 'publish';
if (!$isPublished && !$wasPublished) {
return;
}
$tags = [
"post:{$post->ID}",
"post:slug:{$post->post_name}",
"collection:{$post->post_type}",
];
// Если изменился слаг опубликованного поста, обязательно сбрасываем и старый слаг
if ($wasPublished && $postBefore !== null && $postBefore->post_name !== $post->post_name) {
$tags[] = "post:slug:{$postBefore->post_name}";
}
$this->sendSignedPayload([
'event' => 'post.updated',
'postId' => $post->ID,
'postType' => $post->post_type,
'slug' => $post->post_name,
'tags' => array_values(array_unique($tags)),
'paths' => ["/{$post->post_name}", '/'],
'timestamp' => time(),
]);
}
/**
* Сбрасывает теги таксономии при переименовании категории или тега.
*/
public function dispatchTermRevalidation(int $termId, int $ttId, string $taxonomy): void
{
$this->sendSignedPayload([
'event' => 'term.updated',
'termId' => $termId,
'taxonomy' => $taxonomy,
'tags' => ["term:{$termId}", "taxonomy:{$taxonomy}"],
'paths' => [],
'timestamp' => time(),
]);
}
/**
* Перенаправляет кнопку «Превью» в Gutenberg на защищённый роут Next.js 16 Draft Mode.
*/
public function rewriteGutenbergPreviewLink(string $previewLink, WP_Post $post): string
{
$signature = hash_hmac('sha256', "{$post->ID}:{$post->post_type}", $this->webhookSecret);
return add_query_arg([
'postId' => $post->ID,
'postType' => $post->post_type,
'signature' => $signature,
], $this->nextDraftUrl);
}
/**
* Подписывает JSON-тело через HMAC-SHA256 и отправляет неблокирующий HTTP-вызов.
*
* @param array<string, mixed> $payload
*/
private function sendSignedPayload(array $payload): void
{
$body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhookSecret);
wp_remote_post($this->nextWebhookUrl, [
'timeout' => 2.0,
'redirection' => 0,
'blocking' => false, // Не задерживаем ответ редактору в Gutenberg
'headers' => [
'Content-Type' => 'application/json',
'X-Agentic-Signature-256' => $signature,
'User-Agent' => 'AgenticLab-WP-Origin/7.0',
],
'body' => $body,
]);
}
}
/*
* ============================================================================
* ЧАСТЬ 2: Next.js 16 App Router (TypeScript 5.8) — Route Handler /app/api/revalidate/route.ts
* ============================================================================
*
* import { createHmac, timingSafeEqual } from 'node:crypto';
* import { revalidatePath, revalidateTag } from 'next/cache';
* import { NextRequest, NextResponse } from 'next/server';
*
* export async function POST(request: NextRequest): Promise<NextResponse> {
* const rawBody = await request.text();
* const signatureHeader = request.headers.get('x-agentic-signature-256') ?? '';
* const secret = process.env.WP_WEBHOOK_SECRET ?? '';
*
* const expectedSig = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
* const sigBuffer = Buffer.from(signatureHeader);
* const expectedBuffer = Buffer.from(expectedSig);
*
* if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
* return NextResponse.json({ error: 'Invalid HMAC signature' }, { status: 401 });
* }
*
* const payload = JSON.parse(rawBody) as { tags: string[]; paths: string[]; timestamp: number };
*
* // Защита от Replay-атак (окно допустимости 5 минут)
* if (Math.abs(Math.floor(Date.now() / 1000) - payload.timestamp) > 300) {
* return NextResponse.json({ error: 'Expired webhook timestamp' }, { status: 408 });
* }
*
* for (const tag of payload.tags) {
* revalidateTag(tag, 'default');
* }
* for (const path of payload.paths) {
* revalidatePath(path);
* }
*
* return NextResponse.json({ revalidated: true, tags: payload.tags });
* }
*/
Как защитить эндпоинт ревалидации кэша Next.js 16 (/api/revalidate) от спуфинга и Replay-атак со стороны злоумышленников?