Редактор нажал «Опубликовать». В админке WordPress всё в порядке: статус сменился, новый абзац на месте, заголовок исправлен. Он открывает публичный сайт на Next.js и видит прежний текст. Сначала кажется, что сломалась интеграция, но чаще всё работает ровно так, как её настроили: у одной записи просто два представления. Одно живёт в базе WordPress, второе лежит в кеше фронтенда. Задача состоит в том, чтобы научить первое сообщать второму: «Ты устарело». Причём сообщать так, чтобы под этот сигнал нельзя было подделать запрос и чтобы черновики не вышли на публичный адрес.
Две копии одной записи
В headless-схеме WordPress остаётся местом, где пишут и хранят материалы, а Next.js получает их по API и строит страницы. Серверный fetch в Next.js расширен параметрами постоянного кеширования и ревалидации, и это не браузерный HTTP-кеш, а отдельный серверный слой (документация fetch). Значения тут существенны: cache: 'force-cache' читает и сохраняет ответ, no-store каждый раз идёт к источнику, а next.revalidate задаёт срок жизни в секундах. Значение false означает «хранить, пока не сработает другой механизм инвалидирования». Именно этот последний вариант и делает редакторский сигнал необходимым: если срока жизни нет, страница не обновится сама.
Есть и промежуточный путь: поставить небольшой next.revalidate, скажем несколько минут, и жить с задержкой. Для новостной ленты это иногда приемлемо, для исправления опечатки в юридическом тексте — нет. Таймер полезен как страховка на случай потерянного сигнала, но основной механизм лучше строить на событии: изменение произошло, значит, кеш этой записи надо пометить устаревшим.
Кстати, нельзя считать любой fetch автоматически постоянно кешируемым. Поведение зависит от версии и настроек, поэтому в своём проекте проверяйте его на production-сборке: режим разработки с горячей перезагрузкой ведёт себя иначе и ничего не доказывает о боевом кеше.
Откуда берётся сигнал
Здесь нужна оговорка, которую часто пропускают: «вебхук при публикации» не встроен в каждую установку WordPress. Это либо плагин, либо ваш собственный код. WordPress предоставляет хуки, то есть точки, в которые можно повесить свой обработчик, а отправку и надёжность придётся строить самим.
Первый кандидат — wp_after_insert_post. Он срабатывает после сохранения записи вместе с терминами и метаданными, а обработчик получает ID, объект записи, флаг обновления и post_before (описание хука). Для новой записи post_before равен null, для обновления там прежнее состояние. Это ровно то, что нужно: можно сравнить старый и новый slug, старый и новый статус и понять, что именно изменилось. Если бы вы подписались на хук раньше, термины и мета могли бы быть ещё не сохранены, и фронтенд забрал бы полусобранную запись.
Второй хук — transition_post_status. Он удобен для вопроса «был ли переход». Но документация предупреждает: события переходов вызываются при каждом сохранении, даже когда статус не изменился, и у обновляемой опубликованной записи старый и новый статусы оба равны publish (wp_transition_post_status). Поэтому нельзя читать каждый его вызов как «первая публикация». Практичное разделение такое: wp_after_insert_post служит точкой отправки, а сравнение post_before и текущей записи решает, какие адреса затронуты.
Отправить сообщение можно через wp_remote_post. Функция выполняет HTTP POST и возвращает ответ либо WP_Error; если адрес задаёт пользователь, документация рекомендует wp_safe_remote_post (wp_remote_post). Важно понимать, чего она не даёт: ни очереди, ни автоматических повторов, ни доставки «ровно один раз». Всё это вы добавляете сами или берёте у плагина.
Набросок обработчика на стороне WordPress, в котором адрес и секрет берутся из конфигурации сервера, а не из кода:
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_type !== 'post') return;
$was_public = $post_before && $post_before->post_status === 'publish';
$is_public = $post->post_status === 'publish';
if (!$was_public && !$is_public) return; // черновик остаётся черновиком
$body = wp_json_encode([
'id' => $post_id,
'slug' => $post->post_name,
'old_slug' => $post_before ? $post_before->post_name : null,
'status' => $post->post_status,
'old_status' => $post_before ? $post_before->post_status : null,
'modified' => $post->post_modified_gmt,
]);
wp_remote_post(NEXT_REVALIDATE_URL, [
'timeout' => 5,
'headers' => [
'Content-Type' => 'application/json',
'X-Webhook-Secret' => NEXT_REVALIDATE_SECRET,
],
'body' => $body,
]);
}, 10, 4);Обратите внимание на ранний выход: запись, которая была черновиком и осталась черновиком, не должна дёргать публичный кеш. Ревизии и автосохранения тоже отсекаются, иначе каждый набранный редактором абзац превратится в запрос к фронтенду.
Защищённый обработчик на стороне Next.js
Принимает сигнал Route Handler. Именно там, а также в Server Functions, разрешено вызывать revalidatePath; в Client Components и в Proxy он недоступен (revalidatePath). Обработчик публичный по определению: его адрес видит любой, кто знает структуру сайта, поэтому первая же строка кода проверяет секрет. Без неё любой желающий сможет сбрасывать кеш и устраивать вам лишнюю нагрузку на WordPress.
Что делает вызов на самом деле, стоит понимать точно. Из Route Handler revalidatePath помечает путь для обновления, а обновление происходит при следующем посещении. Это не немедленная пересборка всех затронутых страниц. Тот же принцип у revalidateTag: обновление запускает следующий запрос к данным, а не сам вызов (revalidateTag). Различайте три момента: кеш признан устаревшим, страница действительно перестроена, браузер посетителя получил свежую версию. Ответ вашего webhook-обработчика «200 OK» говорит только о первом. Посетитель, у которого вкладка открыта с утра, увидит новое лишь после перезагрузки или навигации.
Набросок обработчика:
import { revalidatePath, revalidateTag } from 'next/cache';
import { timingSafeEqual } from 'node:crypto';
function same(a: string, b: string) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
export async function POST(req: Request) {
const secret = req.headers.get('x-webhook-secret') ?? '';
if (!same(secret, process.env.REVALIDATE_SECRET!)) {
return new Response('Unauthorized', { status: 401 });
}
const e = await req.json();
const paths = new Set<string>();
if (e.slug) paths.add(`/blog/${e.slug}`);
if (e.old_slug && e.old_slug !== e.slug) paths.add(`/blog/${e.old_slug}`);
for (const p of paths) revalidatePath(p);
revalidateTag('posts', 'max');
return Response.json({ ok: true, paths: [...paths] });
}Секрет лежит в переменной окружения сервера и никогда не попадает в клиентский бандл. Для конкретного URL параметр type можно опустить. А вот для шаблона с динамическим сегментом вроде /blog/[slug] документация требует type: 'page' или 'layout', причём layout затрагивает ещё и вложенные страницы и макеты. Если сигнал приходит с конкретным slug, проще передавать готовый путь и не трогать шаблоны. Отдельный нюанс — rewrites: передавать нужно путь назначения, соответствующий реальному маршруту приложения, а не красивый адрес, который видит посетитель.
Путь, тег или оба
Один путь и один тег отвечают на разные вопросы. Путь говорит: «страница по этому адресу устарела». Тег говорит: «устарели данные с такой меткой, где бы они ни использовались». Документация прямо предупреждает: инвалидирование одного пути не обновляет автоматически другие страницы, использующие те же теги данных; общую зависимость надо инвалидировать отдельно.
Возьмём условный пример. Статья показана на своей странице, в списке блога, в блоке «Читайте также» на соседних статьях и в RSS. Если слить всё в один путь, список и соседние страницы останутся со старым заголовком. Если повесить на весь запрос постов тег posts и инвалидировать его при каждой правке, обновление придёт и туда, но вы заплатите лишней работой при следующих обращениях. Золотая середина — два уровня тегов: общий posts для списков и индивидуальный вида post-123 для конкретной записи. Тег назначается через next.tags в fetch либо через cacheTag в use cache:
const res = await fetch(`${WP}/wp-json/wp/v2/posts?slug=${slug}`, {
next: { tags: ['posts', `post-${id}`], revalidate: 3600 },
});Тут есть логическая ловушка: при запросе по slug вы ещё не знаете ID, пока не получили ответ. Поэтому на запрос «найди запись по slug» разумнее повесить тег по slug, а общий тег оставить для списков. Выбор схемы тегов — ваше проектное решение; документация лишь описывает механику.
Теперь про профиль. Вызов revalidateTag без второго аргумента помечен устаревшим, так что указывайте профиль. 'max' даёт поведение stale-while-revalidate: следующий запрос может получить прежнее содержимое, пока новое загружается в фоне. Для списка статей это обычно нормально: первый посетитель после правки увидит старую версию, второй уже новую. Если же устаревшая выдача недопустима, есть вариант { expire: 0 }: запрос будет ждать обновления. Он подходит для случаев, когда нельзя применять updateTag, а в произвольном webhook Route Handler его как раз применять нельзя. Не смешивайте также res.revalidate из Pages Router: это другая модель, и в App Router её переносить не нужно.
Когда одного нового адреса мало
Самая коварная ситуация начинается не с публикации, а с изменения. Редактор переименовал запись, и slug поменялся с kak-vybrat-vps на vybor-vps. Если обработчик инвалидирует только новый путь, происходит вот что. Новая страница строится правильно. Старая же остаётся в кеше как есть: она продолжает отвечать по старому адресу, на неё ведут внешние ссылки и поисковая выдача. При следующем обращении она, возможно, даже не найдёт запись по старому slug и превратится в 404, но до этого момента будет показывать устаревший текст. Поэтому в событии выше уходят и slug, и old_slug, причём old_slug берётся из post_before, ради чего мы и выбирали этот хук.
Дальше нужно решить, что должен увидеть посетитель старого адреса. Варианты такие:
- Постоянный редирект на новый адрес. Хорош, если запись просто переименовали и на старые ссылки кто-то ходит. Таблицу соответствий можно хранить в WordPress (и отдавать фронтенду) или в конфигурации Next.js.
- Обычный 404 для старого адреса. Годится, если запись удалена по смыслу, а не переименована.
- Ничего не делать. Худший выбор: кеш рано или поздно сам исправится, но когда именно, вы не контролируете.
Теперь снятие с публикации. Запись перевели из publish в draft, pending или private (эти статусы перечислены в описании REST API записей). Фронтенд должен перестать её показывать. Что происходит, если не обработать этот случай? Кеш хранит страницу, которую WordPress уже отказывается отдавать публично. Посетитель продолжает читать «снятый» материал, иногда спустя часы. Если причиной снятия была ошибка или юридическая претензия, это неприятно вдвойне.
Обработчик должен сравнить old_status и status: если запись была опубликована, а стала нет, пути, списки и теги инвалидируются так же, как при публикации. Дальше при следующем посещении страница запросит данные, WordPress не вернёт запись без прав, и компонент должен корректно ответить notFound(). Проверяйте этот сценарий отдельно: легко написать код, который при пустом ответе рисует пустую страницу с кодом 200.
Составим короткую таблицу, что инвалидировать в каждом событии. Это авторская схема, а не требование документации:
| Событие в WordPress | Путь | Тег |
|---|---|---|
| Первая публикация | новый адрес записи | общий тег списков |
| Правка опубликованной записи | адрес записи | общий тег, если меняется заголовок или анонс |
| Смена slug | новый и старый адреса | общий тег списков |
| Снятие с публикации | адрес записи | общий тег списков и блоков со ссылками |
| Правка черновика | ничего | ничего |
Последняя строка таблицы важна не меньше остальных: тихо игнорировать лишнее — тоже часть правильной интеграции.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Повтор сигнала и недоступный фронтенд
Теперь представьте, что в момент нажатия кнопки Next.js перезапускался после выкладки. wp_remote_post вернул WP_Error, а редактор ничего не заметил, потому что его кнопка отработала. Кеш остался старым. Как раз тот случай, где пригодится знание ограничений: функция HTTP-запроса ничего не повторяет и не складывает в очередь. Если надёжность нужна, её придётся добавить.
Минимальный вариант: при ошибке или неуспешном статусе записать событие во временное хранилище (например, в таблицу или опцию WordPress) и повторить через планировщик WordPress с нарастающими паузами. Максимально простой вариант: вместе с событием завести таймерную страховку через next.revalidate на разумный срок. Тогда потерянный сигнал обернётся задержкой, а не вечной устарелостью.
С обратной стороны возникает дубль: WordPress мог повторить запрос, хотя Next.js уже успел его обработать. Хорошая новость в том, что повторная пометка пути устаревшим безвредна: путь будет обновлён при следующем посещении так же, как после первой пометки. Обработчик поэтому идемпотентен по природе своей работы, и громоздкая дедупликация чаще всего не нужна. Но остерегайтесь иного: новый идентификатор попытки сам по себе не даёт идемпотентности. Если ваш обработчик начнёт делать что-то ещё (отправлять уведомления, ставить задачи), дубль начнёт вредить, и тогда нужен реальный ключ события и его хранение. Для чистой инвалидации хватит простого: проверить секрет, разобрать JSON, пометить пути.
Ещё две мелочи, спасающие от ложных тревог. Отвечайте быстро: тяжёлую работу в обработчик не кладите, а таймаут на стороне WordPress держите коротким, чтобы сохранение записи не зависало. И логируйте результат на обеих сторонах: ID записи, список путей, статус ответа. Без этого разговор «у меня не обновилось» превращается в гадание.
Редактору нужен черновик, а не публичная страница
Всё сказанное выше относится к опубликованным материалам. У редактора совершенно другая потребность: посмотреть, как выглядит сохранённый, но не опубликованный текст, на настоящем дизайне. Публичный кеш для этого непригоден, и смешивать эти задачи не стоит. Путь опубликованной страницы управляется инвалидированием, а путь черновика — отдельным режимом.
В Next.js это Draft Mode. Он обходит кеш для редакторских запросов, а обычные посетители продолжают видеть публичную версию (руководство по Draft Mode). В Route Handler вызывается await draftMode() из next/headers, затем enable(); браузер получает cookie __prerender_bypass. Документация описывает, что ответы в этом режиме получают private, no-cache, no-store, а обход касается fetch, use cache, unstable_cache и ISR-кеша ответов. Это же подтверждает руководство по fetch: Draft Mode обходит и чтение, и запись серверного кеша, поэтому черновик не загрязнит кеш публичной страницы.
Первый вопрос о безопасности: как редактор попадает в этот режим? Ссылку «Предпросмотр» в админке WordPress формирует фильтр preview_post_link. Он получает исходный адрес и объект записи, и вы подменяете адрес на свой фронтенд (preview_post_link). Саму ссылку для внешнего Next.js придётся собирать кодом интеграции:
add_filter('preview_post_link', function ($link, $post) {
return add_query_arg([
'secret' => NEXT_PREVIEW_SECRET,
'id' => $post->ID,
], NEXT_PREVIEW_URL);
}, 10, 2);Здесь легко оступиться: секрет оказывается прямо в адресной строке и в истории браузера редактора. Для небольшой команды, где редакторы доверенные, это сознательный компромисс, но он остаётся компромиссом. Более строгий вариант: ссылка несёт только ID и короткоживущую подпись, а общий секрет не покидает серверы. Главное ограничение прямо следует из требований: секрет preview нельзя отдавать публичному клиенту, то есть вшивать в клиентский JavaScript или переменные с публичным префиксом.
Теперь сам обработчик. Документированный защищённый поток делает три вещи: проверяет общий секрет, проверяет через CMS, что запись существует и допустима, и перенаправляет на проверенный путь записи, а не на произвольное значение из query. Последнее важно: если редиректить на то, что пришло в параметре, обработчик превращается в открытый редирект.
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
if (searchParams.get('secret') !== process.env.PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
const id = Number(searchParams.get('id'));
const post = await getPostForPreview(id); // серверный запрос в WP с учётными данными
if (!post) return new Response('Not found', { status: 404 });
(await draftMode()).enable();
redirect(`/blog/${post.slug}`); // путь собран из ответа CMS
}Для подстраховки сравнение секрета можно делать за постоянное время, как в обработчике выше; здесь оно опущено для краткости.
Cookie не равна праву на чтение
Теперь самое важное различие. Draft Mode не авторизует пользователя в WordPress. Cookie всего лишь сообщает Next.js: «для этого браузера обходи кеш». Она ничего не говорит о том, что человек вправе читать этот черновик. Документация ясно говорит: если CMS использует другой URL или учётные данные для черновика, код чтения должен выбрать их по isEnabled, а сама cookie права в CMS не даёт.
Поэтому функция чтения устроена так: в обычном режиме она ходит за публичной версией без учётных данных, в режиме черновика использует серверные учётные данные. Для доступа к REST API документация предлагает два пути: cookie-аутентификацию для вошедших пользователей WordPress с nonce от CSRF и Application Passwords, которые создаются у пользователя и позволяют внешнему клиенту обращаться к API через Basic Authentication по HTTPS. Для production документация предпочитает Application Passwords отдельному basic-auth плагину, передающему обычный пароль (аутентификация в REST API). Серверная сторона Next.js держит такой пароль в переменной окружения и никогда не отправляет его в браузер.
async function getPost(slug: string) {
const { isEnabled } = await draftMode();
const url = `${WP}/wp-json/wp/v2/posts?slug=${slug}` +
(isEnabled ? '&status=draft,publish&context=edit' : '');
const res = await fetch(url, isEnabled
? { headers: { Authorization: `Basic ${process.env.WP_APP_AUTH}` } }
: { next: { tags: ['posts', `post-slug-${slug}`] } });
return (await res.json())[0] ?? null;
}Параметр context принимает view, embed или edit и определяет набор полей ответа, но сам по себе разрешения не даёт. При context=edit метод проверяет право редактирования и возвращает ошибку, если его нет; после этого применяется проверка чтения (права получения записи). А в проверке чтения учитываются тип записи, публичный статус и право read_post для конкретной записи: для стандартного непубличного черновика одного знания ID недостаточно (check_read_permission). Значит, «подставь чужой ID» не сработает, пока серверные учётные данные принадлежат пользователю с нужными правами.
Но тут кроется вывод, о котором стоит подумать. Если фронтенд ходит за любым черновиком одним и тем же служебным пользователем, то права этого пользователя и есть реальный предел доступа. Любой, у кого получилась cookie Draft Mode и кто знает slug, увидит черновик, на чтение которого вправе служебная учётка, а не он сам. Поэтому секрет preview нужно считать ключом от всех черновиков, выдавать только редакторам и менять при смене состава команды. Служебному пользователю стоит давать минимум: роль, которая может читать записи, но не публиковать и не управлять сайтом.
Выход из режима и признаки того, что вы в нём
Редактор должен видеть, что он находится в preview. Иначе он вернётся к публичной странице, будет уверен, что видит боевую версию, и сделает выводы о том, что нужно править. Руководство предлагает индикатор режима и отдельный обработчик с disable() для выхода; для выхода предпочтительнее POST, чем GET, чтобы случайный запрос (например, подгрузка ссылки) не сбрасывал режим. Простая плашка «Режим предпросмотра» с кнопкой выхода экономит команде много недоразумений.
Не забывайте и о статусах. Запись может быть future (запланирована) или pending (на проверке). Preview по ссылке из админки должен работать для всех статусов, для которых редактор вправе открыть запись. Публичный обработчик же показывает только publish.
Когда экземпляров Next.js больше одного
Всё вышесказанное молча предполагает один процесс. На одном VPS с next start и постоянным локальным диском штатное кеширование работает локально, и сигнал инвалидирования попадает ровно в тот кеш, который обслуживает посетителей. Но стоит поднять два экземпляра за балансировщиком, картина меняется: по умолчанию у каждого своя копия кеша (руководство по self-hosting). Webhook приходит в один экземпляр, тот помечает путь устаревшим у себя, а второй продолжает отдавать прежнюю версию. Посетитель, которому балансировщик выдал второй экземпляр, видит старую страницу, а после обновления вкладки может вдруг увидеть новую. Редактор в итоге решает, что «обновляется через раз», и это именно та диагностика, на которую стоит обратить внимание.
Есть несколько способов выбраться из ловушки, и они различаются ценой и сложностью:
- Общий
cacheHandler. Он должен хранить записи в хранилище, доступном всем экземплярам, обрабатывать ошибки и согласованно инвалидировать теги. Руководство также описывает отключение локального кеша в памяти при его использовании, а дляuse cacheесть отдельная настройкаcacheHandlers. Тогда webhook достаточно доставить в любой экземпляр. - Доставка сигнала каждому экземпляру отдельно. Это работает, но WordPress должен знать список адресов, а при недоступности одного из них схема снова упирается в повторы. Хрупко.
- Один экземпляр и вертикальный запас ресурсов. Если нагрузка позволяет, простота выигрывает. Тарифы VPS различаются по CPU, памяти и NVMe-диску, но сами по себе эти числа не гарантируют ёмкость: нагрузку надо измерять на своём сайте.
Нюансы между экземплярами не заканчиваются кешем данных. Между контейнерами рекомендуется распространять одну и ту же сборку, иначе разные версии кода могут по-разному читать общий кеш. А внешний CDN или reverse proxy должен учитывать Cache-Control и различия ключей кеша; неправильная настройка способна отдавать устаревшие варианты при навигации. То есть даже идеальная инвалидация внутри Next.js не гарантирует свежести, если перед ним стоит прокси, который удерживает ответ по своим правилам. Динамические страницы и Draft Mode получают private/no-store поведение, поэтому прокси должен уважать это и не кешировать preview для всех подряд. Подробнее о схеме с двумя экземплярами, общем кеше и обновлении без случайных ошибок написано в материале про два экземпляра Next.js на VPS.
Если вы выбираете сервер под такую связку, подумайте о роли каждой части. WordPress с базой и Next.js можно держать на одном VPS или на разных; на VPS MAATRIX доступен root-доступ, диски NVMe и процессоры AMD EPYC, а линейка стартует с конфигурации Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и растёт до Djed (16 vCPU, 32 ГБ, 320 ГБ). Эти ресурсы не равны обещанию ёмкости, и для небольшого сайта вопрос чаще звучит так: хватит ли памяти одновременно на сборку Next.js и работу PHP с базой. Сборка заметно прожорливее, чем сам next start, так что лучше собирать до выкладки, а не на боевой машине.
Как проверить связку руками
Прежде чем отдавать систему редакторам, пройдите короткий сценарий на production-сборке, а не на next dev, где поведение кеша другое:
- Опубликуйте тестовую запись и убедитесь, что страница появилась, а в списке есть заголовок.
- Исправьте в ней одно слово и сохраните. Откройте страницу дважды: при
revalidateTagс профилемmaxпервое обращение может ещё показать старое. - Поменяйте slug и проверьте оба адреса: новый должен открываться, старый — редиректить или отдавать 404, а не показывать прежний текст.
- Переведите запись в черновик и убедитесь, что публичный адрес перестал её отдавать, а список обновился.
- Остановите Next.js, сохраните запись, запустите снова и посмотрите, что произошло с потерянным сигналом.
- Откройте preview из админки, затем проверьте в другом браузере без cookie тот же адрес: там должна быть публичная версия или 404.
Если у вас больше одного экземпляра, повторяйте шаги несколько раз подряд и следите, не возвращается ли старая версия через раз.
Что получает каждый
Сведём конструкцию воедино. WordPress через хук после сохранения сообщает, какая запись и как изменилась. Защищённый Route Handler проверяет секрет, помечает устаревшими нужные пути и теги, включая старый адрес и списки. Фронтенд при следующем обращении перестраивает страницу, а повторный сигнал ему не страшен. Редактор же идёт другой дорогой: ссылка предпросмотра приводит его через проверенный обработчик в Draft Mode, где серверные учётные данные запрашивают у CMS черновик в обход общего кеша. Посетитель получает опубликованный текст, а черновик остаётся доступен только тем, у кого есть свой разрешённый путь к нему.