Обычно контентный API ставит перед выбором: либо быстро, либо свежо. Мы выбирать не хотим. Ответы кэшируются на нашей стороне, но там никогда не устаревают; у каждого ответа есть ETag, так что неизменившийся контент почти ничего вам не стоит; а правка в CRM попадает в API в ту же секунду, когда редактор нажал «Сохранить». Ниже — как это устроено и как выжать из этого максимум у себя.
Путь правки: от CRM до вашей страницы
Вот весь маршрут изменения — от клавиатуры редактора до экрана посетителя:
Редактор сохраняет
Кто-то поправил опечатку в CRM или прямо на живом сайте. Считается любая правка: текст, картинка, настройки страницы, статья в блоге.Версия контента сайта растёт
Каждая правка увеличивает версию контента сайта. Наш серверный кэш (Redis) привязан к этой версии, поэтому все старые ответы разом становятся неактуальными. Никому не нужно «сбрасывать кэш».Следующий запрос получает свежие данные
Ближайший же запрос к API собирает ответ уже из нового контента. С нашей стороны задержки нет вообще: не нужно ждать истечения TTL, нет очереди на очистку.Кэши между нами и посетителем догоняют
То, что видит посетитель, может чуть отставать: браузер или CDN держат прошлый ответ до 60 секунд и могут показать его ещё разок, пока тихо забирают новый в фоне (это и естьstale-while-revalidate). Если у вас есть свой серверный кэш — к этому прибавляется его срок жизни.
Заголовки кэширования по порядку
Обычный успешный ответ приходит с такими заголовками:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
Access-Control-Allow-Origin: *| Заголовок | Что он говорит кэшам |
|---|---|
Cache-Control: public | Ответ можно хранить кому угодно: браузеру, CDN, прокси. Контент и так публичный — вы же показываете его на сайте. |
max-age=60 | 60 секунд ответ считается свежим, и его можно отдавать повторно, вообще не спрашивая нас. |
stale-while-revalidate=600 | Следующие 10 минут кэш может мгновенно отдать старую копию и параллельно сходить за новой. Этому посетителю — быстро, следующему — свежо. |
ETag: W/"…" | Слабый ETag — хэш тела ответа. Тело то же — и ETag тот же. Пришлите его обратно в If-None-Match, и если ничего не поменялось, получите 304. |
Vary: x-crm-key, Accept-Encoding | Кэш обязан хранить отдельные копии для каждого ключа сайта и каждого сжатия. Два сайта никогда не получат чужой ответ, даже по одному и тому же адресу. |
Cache-Control: no-store | Приходит с каждой ошибкой. 404 на страницу, которую редактор прямо сейчас создаёт, не должен застрять у кого-то в кэше. |
CORS открыт для любого источника, API явно разрешает заголовок запроса If-None-Match и отдаёт ETag в JavaScript — так что всё описанное здесь работает и из браузера.
ETag и ответ 304 Not Modified
ETag — самый дешёвый способ спросить «что-нибудь поменялось?». Запомните ETag из прошлого ответа, в следующий раз отправьте его в If-None-Match — и если контент тот же, придёт 304 Not Modified с пустым телом. Ваш код просто продолжает пользоваться копией, которая у него уже есть.
GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
{ "id": 26, "marker": "home", "name": "Home", "content": { … } }GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
If-None-Match: W/"a41f9c0e7b2d58f3"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"Стоит редактору что-то поменять на этой странице — меняется тело, меняется хэш, и тот же запрос вернёт свежий 200 с новым ETag.
Рецепты кэширования
В браузере: уже всё сделано
Если вы запрашиваете контент прямо из браузера, писать код кэширования не нужно вовсе. Обычный fetch пользуется HTTP-кэшем браузера: 60 секунд отдаёт сохранённый ответ, а потом сам перепроверяет его через If-None-Match.
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// cache: 'default' стоит по умолчанию — max-age и ETag браузер обработает сам
});
const page = await res.json();Не удивляйтесь, что ваш код никогда не видит 304: браузер подменяет его закэшированным 200 ещё до того, как ответ дойдёт до вас. Что произошло на самом деле, видно во вкладке Network в DevTools — там и будут 304 или «disk cache».
На сервере Node: маленький кэш с ETag
Серверный fetch в Node своего HTTP-кэша не имеет, так что каждый вызов идёт прямиком в API. Это лечится маленьким Map: 60 секунд отдаём копию (ровно как max-age), потом спрашиваем через If-None-Match и скачиваем тело, только если оно поменялось.
const API = 'https://back.sitecog.com/content';
const TTL = 60_000; // как max-age=60
type Entry = { etag: string | null; data: unknown; at: number };
// Один процесс — один ключ сайта. Ключей несколько? Добавьте ключ в ключ кэша.
const cache = new Map<string, Entry>();
export async function getContent<T>(path: string): Promise<T> {
const url = API + path;
const cached = cache.get(url);
// 1. Копия ещё свежая — в API даже не ходим
if (cached && Date.now() - cached.at < TTL) return cached.data as T;
// 2. Спрашиваем «изменилось?» с ETag, который у нас уже есть
const headers: Record<string, string> = { 'x-crm-key': process.env.CRM_KEY! };
if (cached?.etag) headers['if-none-match'] = cached.etag;
const res = await fetch(url, { headers });
if (res.status === 304 && cached) {
cached.at = Date.now(); // контент тот же — ещё 60 секунд спокойствия
return cached.data as T;
}
if (!res.ok) throw new Error(`Content API ${res.status} for ${path}`);
const data = (await res.json()) as T;
cache.set(url, { etag: res.headers.get('etag'), data, at: Date.now() });
return data;
}
// использование
const home = await getContent('/v1/pages/home?lang=en');Next.js: revalidate и обновление по требованию
В App Router всю работу делает кэш fetch. revalidate: 60 совпадает с нашим max-age: Next.js держит ответ минуту, а потом обновляет его в фоне.
export default async function Home() {
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60, tags: ['crm-content'] },
});
const page = await res.json();
return <h1>{page.content.hero.content.hero_title.content.en}</h1>;
}Хотите, чтобы правки доходили быстрее? Уменьшите revalidate, но поглядывайте на лимиты. Нужна кнопка «опубликовать прямо сейчас» к большому запуску? Повесьте на запросы тег и заведите маленький маршрут, который этот тег сбрасывает:
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
if (req.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return new Response('Nope', { status: 401 });
}
// В Next.js 16 нужен второй аргумент; в Next.js 15 — просто revalidateTag('crm-content')
revalidateTag('crm-content', { expire: 0 });
return Response.json({ revalidated: true });
}Дёргайте его откуда удобно команде: из скрипта выкладки, из закладки в браузере, из чат-бота, кнопкой в своей админке. Секрет — только ваш, с ключом сайта он никак не связан.
Режим Live: мимо всех кэшей
Когда редактор открывает ваш сайт в режиме Live в CRM, он хочет увидеть правку сразу после сохранения, а не через минуту. Внутри рамки CRM к адресу добавляется ?crm_live=1. Хорошая практика (и ровно так делает наш эталонный клиент): если страница открыта в iframe или в адресе есть crm_live, запрашивайте с cache: 'no-store'. Наша сторона и так свежая, больше ничего не нужно.
const isLive =
window.self !== window.top ||
new URLSearchParams(location.search).has('crm_live');
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
cache: isLive ? 'no-store' : 'default',
});type Props = { searchParams: Promise<{ crm_live?: string }> };
export default async function Home({ searchParams }: Props) {
const live = (await searchParams).crm_live === '1';
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// редактор получает свежие данные на каждый запрос, посетители — копию из кэша
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
// …
}Подробнее о режиме Live и разметке для него — на странице Виджет и разметка.
«Почему я не вижу свою правку?»
Самый популярный вопрос про любой кэш. Пройдитесь по списку сверху вниз: он идёт от «проверить за десять секунд» до «заварите себе чаю».
- Спросите API напрямую. У обычного
curlкэша нет, а наша сторона всегда свежая. Если curl показывает новый текст — с API всё в порядке, старая копия застряла в каком-то кэше по дороге. Если старый — идите дальше по списку. - Правка точно сохранилась? Проверьте в CRM. Для блога: черновики не отдаются никогда, а статья, запланированная на будущее, скрыта до своей даты (и может появиться с опозданием на несколько минут).
- Тот ли сайт? Ключ принадлежит ровно одному сайту. Тестовая и боевая версии с разными ключами читают разный контент.
- Тот ли язык? Запасного языка на сервере нет. Если редактор поправил немецкий текст, а вы выводите английский, ничего видимого не произойдёт. Пустой перевод приходит как
"", и ваш код подстановки может молча показать вместо него другой язык. Подробно — в разделе Языки и запасной язык. - Тот ли блок? Маркеры чувствительны к регистру, а маркер блока уникален только внутри секции:
/v1/blocks/titleбез?section=вернёт самый старый блок с таким маркером — и это может быть вовсе не тот, который правили. - Секция скрыта? Секции с
show: falseвсё равно приходят в ответе. Если редактор скрыл секцию, а на сайте она осталась, — ваш шаблон не смотрит наshow. - Ваш собственный кэш.
revalidate, ISR,Mapв памяти, CDN перед сайтом, страница, собранная один раз при сборке. Главный подозреваемый. - Браузер. До 60 секунд плюс одно фоновое обновление. Жёсткая перезагрузка (Ctrl+Shift+R или Cmd+Shift+R) решает вопрос.
- Правите в режиме Live? Убедитесь, что внутри рамки CRM запросы идут с
cache: 'no-store'(см. выше).
curl -i "https://back.sitecog.com/content/v1/pages/home?lang=en" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"