ISR — компромисс между статикой и рендерингом на каждый запрос, и как всякий компромисс, он полезен ровно до тех пор, пока вы понимаете его цену. Главное, что нужно усвоить сразу: первый посетитель после истечения срока получает устаревшую страницу. Не «может получить» — получит, всегда. Это заложено в механику, а не является недостатком реализации.
Дальше — как этим пользоваться и где ISR применять не стоит.
Проблема: статика vs динамика
В NextJS есть два подхода к рендерингу:
- SSG (Static Site Generation) — страницы генерируются при сборке. Быстро, но данные устаревают.
- SSR (Server-Side Rendering) — страницы генерируются при каждом запросе. Актуально, но нагружает сервер.
ISR — это гибрид: страница статическая, но умеет обновляться в фоне.
Когда использовать ISR: каталоги товаров, блоги, лендинги с редко меняющимся контентом, страницы из CMS. Не подходит для персонализированного контента и real-time данных.
Базовый ISR: revalidate
Самый простой способ — указать revalidate в секундах:
// app/products/[slug]/page.tsx
interface Product {
id: string;
name: string;
price: number;
description: string;
}
async function getProduct(slug: string): Promise<Product | null> {
const res = await fetch(`https://api.example.com/products/${slug}`, {
next: { revalidate: 60 } // Кэш на 60 секунд
});
if (!res.ok) return null;
return res.json();
}
export default async function ProductPage({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) {
notFound();
}
return (
<main>
<h1>{product.name}</h1>
<p className="price">{product.price} ₽</p>
<p>{product.description}</p>
</main>
);
}Как это работает
- Первый посетитель получает статическую страницу из кэша
- Через 60 секунд следующий запрос триггерит фоновую регенерацию
- Пока страница генерируется, посетители видят старую версию
- Когда готово — новая версия заменяет старую
Запрос в 0:00 → Страница v1 (из кэша)
Запрос в 0:30 → Страница v1 (из кэша)
Запрос в 1:01 → Страница v1 (из кэша) + запуск регенерации в фоне
Запрос в 1:02 → Страница v2 (новая версия готова)revalidate на уровне страницы
Можно задать revalidate для всей страницы через экспорт:
// app/blog/page.tsx
// Вся страница будет ревалидироваться каждые 10 минут
export const revalidate = 600;
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return (
<ul>
{posts.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Приоритет revalidate
Если в странице несколько fetch с разными revalidate, NextJS берёт минимальное значение:
// Страница будет ревалидироваться каждые 60 секунд (минимум)
const products = await fetch(url, { next: { revalidate: 60 } });
const categories = await fetch(url, { next: { revalidate: 3600 } });On-Demand ISR: ревалидация по событию
Иногда ждать таймаут неприемлемо — нужно обновить страницу сразу после изменения данных.
Route Handler для ревалидации
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// Проверка секретного токена
const token = request.headers.get('x-revalidate-token');
if (token !== process.env.REVALIDATE_SECRET) {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
);
}
const body = await request.json();
// Ревалидация по пути
if (body.path) {
revalidatePath(body.path);
return NextResponse.json({ revalidated: true, path: body.path });
}
// Ревалидация по тегу
if (body.tag) {
revalidateTag(body.tag);
return NextResponse.json({ revalidated: true, tag: body.tag });
}
return NextResponse.json(
{ error: 'Missing path or tag' },
{ status: 400 }
);
}Использование тегов
Теги позволяют группировать связанные данные:
// app/products/[slug]/page.tsx
async function getProduct(slug: string) {
const res = await fetch(`https://api.example.com/products/${slug}`, {
next: {
tags: ['products', `product-${slug}`],
revalidate: 3600 // Fallback на 1 час
}
});
return res.json();
}
async function getRelatedProducts(categoryId: string) {
const res = await fetch(`https://api.example.com/categories/${categoryId}/products`, {
next: {
tags: ['products', `category-${categoryId}`]
}
});
return res.json();
}Теперь можно ревалидировать:
- Все продукты:
revalidateTag('products') - Конкретный продукт:
revalidateTag('product-iphone-15') - Категорию:
revalidateTag('category-smartphones')
Webhook из CMS
// app/api/cms-webhook/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
// Webhook от Strapi/Sanity/Contentful
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-webhook-signature');
// Проверяем подпись (упрощённо)
if (!verifySignature(signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = await request.json();
// Обрабатываем разные типы событий
switch (payload.event) {
case 'entry.update':
case 'entry.create':
case 'entry.delete':
const { model, entry } = payload;
// Ревалидируем по модели и ID
revalidateTag(model); // 'products', 'posts', etc.
revalidateTag(`${model}-${entry.id}`);
// Для постов — ревалидируем главную блога
if (model === 'posts') {
revalidateTag('blog-list');
}
break;
}
return NextResponse.json({ success: true });
}
function verifySignature(signature: string | null): boolean {
// Реальная проверка HMAC подписи
return true;
}Защищайте endpoint ревалидации! Без проверки токена/подписи злоумышленник может спамить ревалидацией и положить сервер.
generateStaticParams + ISR
Для динамических роутов комбинируем предгенерацию и ISR:
// app/products/[slug]/page.tsx
// Предгенерируем топ-100 товаров при билде
export async function generateStaticParams() {
const products = await fetch('https://api.example.com/products/top100')
.then(r => r.json());
return products.map((product: { slug: string }) => ({
slug: product.slug
}));
}
// Остальные страницы генерируются по запросу
export const dynamicParams = true; // default: true
// Ревалидация каждые 10 минут
export const revalidate = 600;Паттерн: популярное статически, редкое — по запросу
export async function generateStaticParams() {
// Только популярные товары — остальные ISR
const popular = await db.product.findMany({
where: { views: { gt: 1000 } },
select: { slug: true },
take: 500
});
return popular.map(p => ({ slug: p.slug }));
}«Устаревшая, пока регенерируется» — не абстракция, а решение, которое вы принимаете за пользователя. Для статьи блога показать версию часовой давности безобидно. Для страницы товара это означает, что покупатель видит старую цену или наличие — и добавляет в корзину то, чего нет.
Правило простое: ISR для контента, рендеринг на запрос — для состояния. Описание товара, характеристики, отзывы — контент, они прекрасно живут в ISR. Цена, остаток, персональные условия — состояние, и их нужно либо получать на клиенте отдельным запросом, либо рендерить на каждый запрос.
Гибридный вариант обычно и оказывается правильным: страница целиком в ISR, а цена и наличие подгружаются динамически. Так вы получаете и скорость отдачи, и актуальность там, где она критична.
Типичные ошибки
1. revalidate = 0 ≠ SSR
// ❌ Это НЕ SSR — это отключение кэша fetch
export const revalidate = 0;
// ✅ Для SSR используйте:
export const dynamic = 'force-dynamic';2. Забыли про layout
Layout тоже кэшируется! Если данные в layout, они не обновятся при ревалидации страницы:
// app/layout.tsx
async function getCategories() {
// ❌ Этот запрос кэшируется отдельно от страниц
return fetch('/api/categories').then(r => r.json());
}
// ✅ Добавьте тег для ревалидации
async function getCategories() {
return fetch('/api/categories', {
next: { tags: ['categories'], revalidate: 300 }
}).then(r => r.json());
}3. Не учли зависимости
// Страница товара зависит от:
// - данных товара
// - связанных товаров
// - отзывов
// - наличия на складе
// Ревалидация только по product-${id} не обновит отзывы!
// Используйте несколько тегов:
next: { tags: ['products', `product-${id}`, `reviews-${id}`] }4. Слишком агрессивная ревалидация
// ❌ Ревалидация всего сайта при любом изменении
revalidatePath('/', 'layout');
// ✅ Точечная ревалидация
revalidatePath(`/products/${slug}`);
revalidateTag(`product-${id}`);Мониторинг ISR
Добавьте заголовки для отладки:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Показываем время генерации страницы
response.headers.set('x-generated-at', new Date().toISOString());
return response;
}В DevTools смотрите заголовок x-nextjs-cache:
HIT— страница из кэшаSTALE— отдаётся старая версия, идёт регенерацияMISS— страница сгенерирована заново
Итоги
| Сценарий | Решение |
|---|---|
| Данные меняются редко (раз в час) | revalidate: 3600 |
| Нужно обновить сразу после изменения | On-demand ISR + webhook |
| Разные данные с разной частотой | Теги + точечная ревалидация |
| Критичная актуальность (цены, наличие) | SSR или короткий revalidate |
ISR — мощный инструмент, но требует понимания, как данные связаны между собой. Начните с простого revalidate, добавляйте теги по мере роста сложности.
Три вещи, о которых стоит помнить при внедрении.
Ревалидация по требованию требует защиты. Эндпоинт, сбрасывающий кэш, доступен снаружи. Без секрета его найдут и будут дёргать — и вы получите постоянную регенерацию страниц, то есть нагрузку вместо кэша. Секрет в заголовке и проверка через сравнение за постоянное время — обязательный минимум.
Проверяйте поведение на боевом окружении, а не в dev. В режиме разработки кэширование работает иначе, и «у меня ревалидация не срабатывает» чаще всего означает «я проверяю не там». Заголовок x-nextjs-cache смотрите на собранном приложении.
Следите за объёмом кэша. Каждый уникальный набор параметров маршрута — это отдельная сохранённая страница. Если параметры приходят из адресной строки и не ограничены белым списком, кэш растёт неограниченно — та же проблема, что и у кэша фильтров в любой другой системе, и решается она так же: валидацией параметров, а не расширением диска.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.