В архитектуре современных контентных платформ на базе Headless CMS (Sanity, Strapi, WordPress Headless, Contentful) ключевым требованием редакторов и контент-менеджеров является возможность видеть изменения в материалах в реальном времени до их публичного релиза.
Однако в высокопроизводительных приложениях Next.js 16 страницы собираются статически (SSG/ISR) либо кэшируются на глобальных CDN через директиву 'use cache'. Если CMS отдает неопубликованные черновики по тому же API, возникает конфликт между скоростью доставки публичного контента и необходимостью видеть свежие правки.
Для решения этой задачи Next.js 16 предоставляет нативный механизм Draft Mode (Режим черновика).
Draft Mode функционирует на основе защищенной куки с криптографической подписью (__prerender_bypass). Когда кука активна, серверный рантайм обходит кэш данных, обращается к Preview API системы управления контентом и рендерит актуальный черновик статьи. Публичные пользователи продолжают получать закэшированный статический HTML из CDN с нулевой задержкой.
Ментальная модель интеграции Headless CMS строится на разделении двух потоков данных: публичного конвейера публикации через вебхуки и приватного защищенного туннеля предпросмотра (Preview Tunnel):
1. Публичный конвейер (Пользователи сайта):
Headless CMS ──[Вебхук публикации статьи]──► Next.js revalidateTag('posts')
│
▼
Сброс CDN-кэша
Публичный юзер видит релиз
2. Приватный конвейер (Редактор в студии CMS):
Редактор жмет "Live Preview"
│
▼
GET /api/draft?secret=SEC_984&slug=quantum-computing
│
▼
┌─────────────────────────────────────────────────────┐
│ Route Handler: /api/draft/route.ts │
│ 1. Криптографическая валидация secret-токена │
│ 2. Вызов const draft = await draftMode(); │
│ 3. draft.enable() -> Установка куки bypass │
│ 4. Редирект на /posts/quantum-computing │
└─────────────────────────────────────────────────────┘
│
▼
Рендеринг страницы:
const { isEnabled } = await draftMode();
if (isEnabled) {
// Запрос в CMS Preview API без кэширования!
}Для редактора окно предпросмотра встраивается прямо в интерфейс CMS через <iframe>. Двусторонняя связь между Next.js и редактором осуществляется по защищенному каналу window.postMessage, что позволяет подсвечивать редактируемые текстовые блоки (Content Source Maps).
Интерактивный Live Preview в Headless CMS
┌────────────────────────────────────────────────────────────────────────┐
│ CMS Редакторская панель (Sanity Studio / Strapi / Headless WordPress) │
│ │
│ ┌──────────────────────┐ postMessage (Событие изменения текста) │
│ │ Поля ввода CMS: │ ──────────────────────────────────────────┐ │
│ │ Title: "Новый заголовок" │ │
│ └──────────────────────┘ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ <iframe src="https://site.io/posts/slug"> │ │
│ │ │ │
│ │ Next.js 16 App Router (Draft Mode Active) │ │
│ │ │ │
│ │ - Cookie: __prerender_bypass │ │
│ │ - Кэш отключен (No-Store) │ │
│ │ - Отображение изменений за < 100 мс без сохранения черновика │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘Контент-менеджер в интерфейсе CMS нажимает кнопку предпросмотра, формируя защищенный URL к эндпоинту /api/draft?secret=TOKEN&slug=URL.
Route Handler проверяет криптографический секрет и вызывает draft.enable(), устанавливая зашифрованную HttpOnly SameSite cookie __prerender_bypass.
Серверный компонент проверяет await draftMode().isEnabled. Если флаг активен, кэширующие директивы игнорируются, а данные тянутся из черновиков CMS.
На странице монтируется сервисный бейдж 'Режим предпросмотра активен' со ссылкой на /api/disable-draft для безопасного выхода контент-менеджера.
Когда редактор нажимает кнопку 'Опубликовать', CMS отправляет POST-вебхук, вызывающий revalidateTag(`post:${id}`), мгновенно обновляя публичный CDN.
Каким образом Next.js предотвращает несанкционированный просмотр неопубликованных черновиков коммерческих статей посторонними пользователями?
Редакторская коллегия готовит важный пресс-релиз компании с отложенным эмбарго. Материал просматривается юристами, пиар-отделом и руководством через защищенную ссылку предварительного просмотра. Публичный релиз в сеть происходит строго в установленное время нажатием кнопки публикации в CMS без ожидания пересборки сайта.
Маркетологи интернет-магазина наполняют описания новых коллекций одежды за неделю до старта продаж. В режиме Live Preview мерчанты видят реальный вид карточек товаров, баннеров и лукбуков в точном соответствии с продакшен-дизайном, исключая версточные баги до запуска промо-кампании.
Технические писатели редактируют статьи сложной структуры с интерактивными сниппетами кода и таблицами. Встроенный фрейм Live Preview позволяет в реальном времени отслеживать корректность парсинга Markdown и форматирования формул.
Переводчики и редакторы региональных филиалов проверяют верстку страниц на немецком, арабском или японском языках. Они мгновенно видят, не ломает ли длинная немецкая строка сетку заголовка и корректно ли работает RTL-верстка для арабского языка.
app/api/draft/route.ts#В Next.js 16 функция draftMode() является асинхронной и импортируется из next/headers:
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { getPostPreviewBySlug } from '@/lib/cms';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// 1. Проверяем валидность секретного токена
if (secret !== process.env.HEADLESS_CMS_PREVIEW_SECRET) {
return new Response('Invalid security token', { status: 401 });
}
// 2. Проверяем наличие параметра пути
if (!slug) {
return new Response('Missing slug parameter', { status: 400 });
}
// 3. Верифицируем существование статьи в CMS (включая неопубликованные записи)
const post = await getPostPreviewBySlug(slug);
if (!post) {
return new Response('Post not found in CMS', { status: 404 });
}
// 4. Включаем Draft Mode (выставляет защищенную куку __prerender_bypass)
const draft = await draftMode();
draft.enable();
// 5. Выполняем безопасный редирект на целевой пост
// Исключаем Open Redirect атаки, ограничивая редирект локальным путем
redirect(`/posts/${post.slug}`);
}app/api/disable-draft/route.ts#import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const redirectPath = searchParams.get('redirect') || '/';
const draft = await draftMode();
// Удаляет куку __prerender_bypass
draft.disable();
redirect(redirectPath);
}Редактор в CMS кликает на предпросмотр. CMS открывает iframe с запросом к /api/draft с подписанным токеном.
Секретный токен хранится в защищенных переменных окружения CMS и Next.js.
Next.js верифицирует токен и устанавливает куку __prerender_bypass со сроком действия сессии.
Кука шифруется с использованием приватного серверного ключа Next.js.
Серверный компонент определяет isEnabled === true, игнорирует статический кэш и делает no-store запрос в Preview API.
Черновик отображается со всеми свежими нерелизнутыми изменениями.
Клиентский скрипт Live Preview слушает postMessage от окна CMS и мгновенно мутирует локальные данные без перезагрузки.
Задержка синхронизации текста между вводом в CMS и экраном составляет менее 50 мс.
app/posts/[slug]/page.tsx#import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import { getPublishedPost, getDraftPost } from '@/lib/cms';
import { PreviewBanner } from '@/components/preview-banner';
import { LivePreviewListener } from '@/components/live-preview-listener';
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export default async function PostPage(props: PostPageProps) {
const { slug } = await props.params;
// Next.js 16: асинхронное получение статуса черновика
const draft = await draftMode();
const isDraft = draft.isEnabled;
// Если активен Draft Mode, запрашиваем черновик с Preview API CMS в обход кэша
// В обычном режиме запрашиваем закэшированный опубликованный контент
const post = isDraft
? await getDraftPost(slug)
: await getPublishedPost(slug);
if (!post) {
notFound();
}
return (
<article className="max-w-3xl mx-auto px-4 py-12">
{/* Баннер оповещения редактора о включенном режиме предпросмотра */}
{isDraft && (
<PreviewBanner redirectPath={`/posts/${slug}`} />
)}
<header className="mb-8">
<h1 className="text-4xl font-extrabold tracking-tight mb-4 text-text-primary">
{post.title}
</h1>
<div className="flex items-center gap-4 text-sm text-text-muted">
<span>Автор: {post.author.name}</span>
<span>•</span>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('ru-RU')}
</time>
{isDraft && (
<span className="px-2 py-0.5 rounded bg-warning/20 border border-warning/40 text-warning text-xs font-bold uppercase">
Черновик
</span>
)}
</div>
</header>
{/* Основное тело статьи */}
<div
className="prose dark:prose-invert max-w-none leading-relaxed"
dangerouslySetInnerHTML={{ __html: post.htmlContent }}
/>
{/* Клиентский слушатель postMessage для мгновенного Live Preview */}
{isDraft && (
<LivePreviewListener initialPost={post} />
)}
</article>
);
}app/api/webhooks/cms/route.ts#Когда редактор публикует статью, CMS отправляет защищенный HTTP POST запрос, инвалидирующий кэш конкретной сущности через revalidateTag:
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
export async function POST(req: NextRequest) {
const bodyText = await req.text();
const signature = req.headers.get('x-cms-signature');
const webhookSecret = process.env.CMS_WEBHOOK_SECRET || 'secret';
// 1. Верификация HMAC-SHA256 подписи вебхука
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(bodyText)
.digest('hex');
if (signature !== expectedSignature) {
return NextResponse.json({ error: 'Invalid HMAC signature' }, { status: 401 });
}
const payload = JSON.parse(bodyText);
const { event, model, entry } = payload;
// 2. Обрабатываем публикацию статьи
if (model === 'post' && (event === 'publish' || event === 'update')) {
// Инвалидируем тег конкретного поста и общего списка статей
revalidateTag(`post:${entry.slug}`, 'hard');
revalidateTag('posts:list', 'hard');
return NextResponse.json({
revalidated: true,
tags: [`post:${entry.slug}`, 'posts:list'],
now: Date.now(),
});
}
return NextResponse.json({ revalidated: false, message: 'Event ignored' });
}В эндпоинте /api/draft разработчик написал redirect(request.nextUrl.searchParams.get('slug')). Специалист по безопасности признал этот код критической уязвимостью. В чем заключается опасность?
Контент-менеджер переходит по превью-ссылке, активирует Draft Mode, а затем продолжает просматривать сайт как обычный посетитель. В результате все страницы для него рендерятся динамически без кэша с задержкой в несколько секунд.
Как избежать: Всегда рендерите плавающую плашку <PreviewBanner /> с кнопкой выхода, ведущей на /api/disable-draft, чтобы редактор мог вернуться в стандартный публичный режим кэширования.
Если внутри функции getDraftPost(slug) по ошибке оставить стандартный fetch() с кэшированием или забыть добавить заголовок cache: 'no-store', редактор увидит первую версию черновика, но последующие правки в CMS не отобразятся до истечения TTL кэша.
Как избежать: Для запросов черновиков всегда явно выставляйте fetch(url, { cache: 'no-store' }) и передавайте актуальные заголовки авторизации Preview API.
Если /api/webhooks/cms не проверяет криптографическую подпись x-cms-signature, любой внешний пользователь может отправлять фиктивные запросы на инвалидацию и вызывать непрерывный сброс кэша платформы (Denial of Service через Cache Busting).
Как избежать: Всегда верифицируйте HMAC-подпись входящего вебхука на основе секретного ключа CMS_WEBHOOK_SECRET.
В Next.js 16 обращение const draft = draftMode() без await выбрасывает исключение во время рендеринга:
Error: draftMode() should be awaited before using its properties.
Как избежать: Всегда используйте асинхронный вызов const draft = await draftMode();.