title: >- Архітектура Headless WP + Next.js 16: On-Demand ISR, HMAC Webhooks та Draft Mode description: >- Проектування відмовостійкої зв'язки WordPress 7.0+ (Origin CMS) та Next.js 16 App Router: гранулярна інвалідація кешу через 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 не віддає неопубліковані записи, а фронтенд не знає про існування ревізії.Еталонним вирішенням першої проблеми є Подієво-орієнтована гранулярна інвалідація (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 < 20ms). Монетний двір узагалі не обслуговує читацький трафік безпосередньо; він лише розсилає криптографічно завірені телеграми (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.
В інженерній практиці 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 });
* }
*/
Стандартизуйте іменування тегів між 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.