В архітектурі Headless WordPress ключовим бар'єром для роботи редакторів контенту є проблема ізоляції чернеток: клієнтський фронтенд Next.js обслуговує користувачів із незмінного статичного кешу (ISR/SSG) на Edge CDN, а неопубліковані матеріали (чернетки, заплановані публікації та ревізії) закриті в базі даних WordPress і недоступні для публічного читання.
Для вирішення цього завдання в сучасному стеку Next.js 16 (App Router) використовується механізм Draft Mode (який прийшов на зміну застарілому previewData з Pages Router).
Механіка роботи Draft Mode:
preview_post_link у WordPress перехоплює стандартне посилання та перенаправляє браузер редактора на захищений ендпоінт фронтенду: /api/draft?secret=...&id=42&slug=my-draft.draftMode().enable().__prerender_bypass з атрибутами SameSite=Lax, Secure та HttpOnly.__prerender_bypass, сервер Next.js вимикає статичний кеш (Data Cache та Full Route Cache) виключно для поточної сесії редактора, переходячи в режим рендерингу за вимогою.const { isEnabled } = await draftMode();. Якщо режим активний, компонент використовує приватний токен авторизації (Application Password / Scoped JWT) для запиту найсвіжішої ревізії чернетки із захищеного ендпоінта WordPress API (/wp/v2/posts/{id}/revisions).Для забезпечення безпеки корпоративного рівня система попереднього перегляду зобов'язана нейтралізувати вектор уразливості Open Redirect (перенаправлення на шкідливі домени через підроблений параметр slug), забезпечувати валідацію часових міток токенів та надавати інтерфейс виходу з режиму чернетки (/api/disable-draft).
Наскрізний протокол авторизованого рукостискання та рендерингу ревізії чернетки:
+-----------------------------------------------------------------------------------------+
| АДМІН-ПАНЕЛЬ WORDPRESS (Gutenberg) |
+-----------------------------------------------------------------------------------------+
| [Редактор натискає кнопку "Попередній перегляд"] |
| | |
| v |
| [Хук: preview_post_link] ---> Генерація посилання з тимчасовим токеном: |
| https://frontend.com/api/draft?secret=TOKEN&id=42... |
+-----------------------------------------------------------------------------------------+
|
| 1. Перехід браузера (GET)
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 ROUTE HANDLER |
+-----------------------------------------------------------------------------------------+
| [/api/draft/route.ts] |
| | |
| +---> [Звірка секрету: crypto.timingSafeEqual()] |
| | |
| +---> [draftMode().enable()] ---> Встановлення куки: __prerender_bypass |
| | |
| +---> [307 Redirect на /blog/p1] |
+-----------------------------------------------------------------------------------------+
|
| 2. Редирект із кукою __prerender_bypass
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 REACT SERVER COMPONENT (RSC) |
+-----------------------------------------------------------------------------------------+
| [app/blog/[slug]/page.tsx] |
| | |
| +---> const { isEnabled } = await draftMode(); // TRUE! |
| | | |
| | +---> [ОБХІД КЕШУ CDN]: Запит ревізії чернетки з M2M Bearer токеном |
| | |
| +---> Рендеринг закритого контенту + Плашка "Режим перегляду [Вийти]" |
+-----------------------------------------------------------------------------------------+Вбудований API фреймворку Next.js для тимчасового вимкнення шару статичного кешування для авторизованих користувачів без перезбірки сторінок.
Криптографічно підписана кука, що встановлюється сервером Next.js при активації Draft Mode, яка сигналізує Edge-маршрутизатору про необхідність динамічного рендерингу сторінки.
Механізм вилучення останньої збереженої авторевізії (autosave) або чернетки з таблиці wp_posts з використанням привілейованих облікових даних CMS.
Сувора валідація відносного шляху перенаправлення, що унеможливлює передачу посилань на сторонні фішингові сайти через параметр slug або redirect.
Ізольований системний обліковий запис із мінімальними привілеями читання неопублікованого контенту (read_private_posts), що використовується сервером Next.js для взаємодії з WordPress.
WordPress перехоплює клік по кнопці через фільтр preview_post_link та генерує захищене посилання на фронтенд із тимчасовим токеном.
Обробник app/api/draft/route.ts виконує timing-safe звірку секрету та перевіряє наявність публікації у WordPress API.
Next.js викликає draftMode().enable() та надсилає в заголовку Set-Cookie куку __prerender_bypass, виконуючи редирект 307 на перевірений відносний шлях.
Серверний компонент виявляє draftMode().isEnabled = true та запитує найсвіжішу ревізію публікації через M2M Application Password.
Сторінка рендериться із закріпленим банером 'Активний режим перегляду', що містить посилання на /api/disable-draft для повернення до публічного кешу.
Якщо обробник /api/draft сліпо довіряє вхідному параметру slug або redirect:
// КРИТИЧНА ВРАЗЛИВІСТЬ:
redirect(searchParams.get('slug'));Зловмисник може сформувати шкідливе посилання виду https://frontend.com/api/draft?secret=VALID&slug=//attacker.com/login, перенаправляючи користувачів сайту на фішинговий ресурс.
Стандарт безпеки:
Відносний URL зобов'язаний починатися строго з одного слешу / і не повинен містити подвійних слешів // або двокрапок:
function sanitizeRedirectPath(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}Нижче наведено готову інфраструктурну зв'язку: PHP-фільтр WordPress для формування тимчасових підписаних посилань та захищені Route Handlers Next.js 16 для активації та вимкнення режиму попереднього перегляду.
// ============================================================================
// ЧАСТИНА 1: Next.js 16 Route Handler активації (app/api/draft/route.ts)
// ============================================================================
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
/**
* Валідація відносного шляху перенаправлення для унеможливлення Open Redirect.
*/
function sanitizeRedirect(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const id = searchParams.get('id');
const configuredSecret = process.env.WORDPRESS_PREVIEW_SECRET;
if (!configuredSecret || !secret) {
return NextResponse.json({ message: 'Invalid preview token' }, { status: 401 });
}
// Порівняння секретів із захистом від таймінг-атак
const secretBuffer = Buffer.from(secret, 'utf8');
const expectedBuffer = Buffer.from(configuredSecret, 'utf8');
if (
secretBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(secretBuffer, expectedBuffer)
) {
return NextResponse.json({ message: 'Unauthorized preview access' }, { status: 401 });
}
// 1. Активація режиму чернетки (встановлює куку __prerender_bypass)
const draft = await draftMode();
draft.enable();
// 2. Безпечне перенаправлення на сторінку статті
const redirectPath = slug ? `/blog/${slug}` : (id ? `/blog/?p=${id}` : '/');
redirect(sanitizeRedirect(redirectPath));
}
// ============================================================================
// ЧАСТИНА 2: Next.js 16 Route Handler вимкнення (app/api/disable-draft/route.ts)
// ============================================================================
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const redirectUrl = sanitizeRedirect(searchParams.get('redirect'));
// Вимикаємо режим чернетки (очищає куку __prerender_bypass)
const draft = await draftMode();
draft.disable();
redirect(redirectUrl);
}
// ============================================================================
// ЧАСТИНА 3: React Server Component банера (components/PreviewBanner.tsx)
// ============================================================================
export async function PreviewBanner({ currentPath }: { currentPath: string }) {
const { isEnabled } = await draftMode();
if (!isEnabled) {
return null;
}
return (
<aside className="sticky top-0 z-50 flex items-center justify-between bg-accent-amber/90 px-4 py-2 text-xs font-semibold text-black backdrop-blur-md shadow-md">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-600 animate-pulse" />
<span>Увага: Активний режим попереднього перегляду (чернетки та ревізії)</span>
</div>
<a
href={`/api/disable-draft?redirect=${encodeURIComponent(currentPath)}`}
className="rounded bg-black px-3 py-1 text-white hover:bg-neutral-800 transition-colors"
>
Вийти з перегляду
</a>
</aside>
);
}
// ============================================================================
// ЧАСТИНА 4: WordPress Preview Filter (wp-content/mu-plugins/agentic-preview.php)
// ============================================================================
/*
<?php
declare(strict_types=1);
namespace AgenticLab\Integration;
final class AgenticPreviewLinkFilter {
private const string FRONTEND_DRAFT_URL = 'https://frontend.agenticlab.com/api/draft';
public static function init(): void {
add_filter('preview_post_link', [self::class, 'filter_preview_link'], 10, 2);
}
public static function filter_preview_link(string $link, \WP_Post $post): string {
$secret = defined('WORDPRESS_PREVIEW_SECRET') ? WORDPRESS_PREVIEW_SECRET : '';
if (empty($secret)) {
return $link;
}
return add_query_arg([
'secret' => $secret,
'id' => $post->ID,
'slug' => $post->post_name ?: 'draft-' . $post->ID,
], self::FRONTEND_DRAFT_URL);
}
}
add_action('init', [AgenticPreviewLinkFilter::class, 'init']);
*/
Генерація 32-байтного криптографічного ключа random_bytes(32) та збереження у змінні WORDPRESS_PREVIEW_SECRET на бекенді та фронтенді.
Створює спільний закритий канал довіри між системами.
Створення ендпоінтів app/api/draft/route.ts та app/api/disable-draft/route.ts із санітизацією Open Redirect та викликом draftMode().
Керує життєвим циклом сесійної куки __prerender_bypass.
Перенаправлення кнопки «Попередній перегляд» у Gutenberg на створений API-маршрут фронтенду з передачею ID публікації та токена.
Забезпечує нативний користувацький досвід для редакторів.
Впровадження перевірки draftMode().isEnabled у серверні компоненти, перемикання джерела даних на закриті ревізії та рендеринг плашки виходу.
Гарантує коректне відображення виправлень та можливість повернення в кеш.
Реалізація режиму попереднього перегляду в сучасній архітектурі Headless WordPress будується на трьох правилах:
draftMode().enable() в App Router із захищеною сесійною кукою __prerender_bypass.