Перейти к содержимому

9. ISR — Инкрементальная регенерация

🔄 ISR — Инкрементальная статическая регенерация

Заголовок раздела «🔄 ISR — Инкрементальная статическая регенерация»

ISR (Incremental Static Regeneration) — это магический компромисс между SSG и SSR. Представь: у тебя есть статичный ресторанный меню-сайт. Меню меняется раз в неделю. SSG слишком жёсткий (нужен редеплой при каждом изменении), SSR слишком дорогой (запрос к БД при каждом посещении). ISR — идеальный баланс! 🍕

SSG: [build] → [HTML] → пользователи (всегда одинаковый)
SSR: [запрос] → [рендер] → [HTML] → пользователь (каждый раз новый рендер)
ISR: [build] → [HTML] → пользователи (быстро как SSG!)
↕ каждые N секунд
[регенерация] → [новый HTML] → следующий пользователь

Самый простой способ ISR — экспортировать revalidate:

app/blog/page.tsx
// Обновлять страницу не чаще раз в 1 час
export const revalidate = 3600;
export default async function BlogPage() {
const posts = await fetch('https://api.blog.com/posts', {
next: { revalidate: 3600 }, // То же самое на уровне fetch
}).then(r => r.json());
return (
<main>
<h1>Блог</h1>
<p>Обновляется каждый час</p>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</main>
);
}
// На уровне отдельного fetch (более гибко):
export default async function ShopPage() {
// Товары обновляются раз в час
const products = await fetch('/api/products', {
next: { revalidate: 3600 },
}).then(r => r.json());
// Цены обновляются каждые 5 минут
const prices = await fetch('/api/prices', {
next: { revalidate: 300 },
}).then(r => r.json());
// Баннер никогда не обновляется (статика)
const banner = await fetch('/api/banner', {
cache: 'force-cache',
}).then(r => r.json());
return <ShopLayout products={products} prices={prices} banner={banner} />;
}

Вместо ожидания таймера — сбрасываем кеш когда данные изменились:

app/products/[id]/page.tsx
// 1. Помечаем fetch тегами
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await fetch(`https://api.shop.com/products/${id}`, {
next: {
tags: ['products', `product-${id}`], // Теги для этого запроса
},
}).then(r => r.json());
return <ProductDetail product={product} />;
}
// app/products/page.tsx
export default async function ProductsPage() {
const products = await fetch('https://api.shop.com/products', {
next: { tags: ['products'] }, // Тот же тег 'products'
}).then(r => r.json());
return <ProductsList products={products} />;
}
app/api/revalidate/route.ts
// 2. Создаём API для сброса кеша
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
// Проверяем секрет (защита от несанкционированных сбросов)
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json(
{ error: 'Invalid secret' },
{ status: 401 }
);
}
const body = await request.json();
if (body.tag) {
revalidateTag(body.tag); // Сбрасываем по тегу
return NextResponse.json({
revalidated: true,
type: 'tag',
tag: body.tag
});
}
if (body.path) {
revalidatePath(body.path); // Сбрасываем по пути
return NextResponse.json({
revalidated: true,
type: 'path',
path: body.path
});
}
return NextResponse.json({ error: 'No tag or path provided' }, { status: 400 });
}
// 3. Вызываем из CMS/webhook
// Например, Sanity CMS webhook при обновлении продукта:
// POST /api/revalidate?secret=MY_SECRET
// Body: { "tag": "products" }
// Или для конкретного продукта:
// Body: { "tag": "product-123" }
// Пример из Server Action:
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data });
revalidateTag('products'); // Сбросить список всех продуктов
revalidateTag(`product-${id}`); // Сбросить конкретный продукт
}

import { revalidatePath } from 'next/cache';
// Сбросить конкретную страницу
revalidatePath('/blog'); // Только /blog
revalidatePath('/blog/hello-world'); // Только /blog/hello-world
// Сбросить все страницы по маршруту
revalidatePath('/blog/[slug]', 'page'); // Все страницы блога
revalidatePath('/blog', 'layout'); // Layout + все дочерние страницы
// Использование в Server Action
'use server';
export async function publishPost(id: string) {
await db.post.update({
where: { id },
data: { published: true, publishedAt: new Date() }
});
const post = await db.post.findUnique({ where: { id } });
revalidatePath('/blog'); // Обновить список постов
revalidatePath(`/blog/${post?.slug}`); // Обновить страницу поста
revalidatePath('/', 'layout'); // Обновить главную (если там список постов)
}

Когда используешь ORM напрямую — нет встроенного кеша. unstable_cache спасает:

import { unstable_cache } from 'next/cache';
import { db } from '@/lib/prisma';
// Создаём кешированную функцию
const getCachedProducts = unstable_cache(
async (category: string) => {
console.log('DB query!'); // Выполнится только при кеш-мисс
return await db.product.findMany({
where: { category, published: true },
include: { images: true },
orderBy: { createdAt: 'desc' },
});
},
['products-by-category'], // Ключ кеша (уникальный идентификатор)
{
revalidate: 300, // Обновлять каждые 5 минут
tags: ['products'], // Теги для on-demand сброса
}
);
// Использование
export default async function CategoryPage({ params }: Props) {
const { category } = await params;
// Первый вызов: запрос к БД
// Последующие: из кеша (до истечения revalidate)
const products = await getCachedProducts(category);
return <ProductGrid products={products} />;
}

Понимание механизма поможет избежать сюрпризов:

Запрос 1 (t=0s): кеш пуст
→ Рендерим страницу (SSG/ISR)
→ Сохраняем в кеш
→ Отвечаем пользователю: свежие данные ✅
Запрос 2 (t=30min): кеш актуален (revalidate=3600)
→ Отдаём из кеша: быстро ⚡
Запрос 3 (t=2h): кеш устарел (>3600s)
→ Отдаём УСТАРЕВШИЕ данные (stale): быстро ⚡
→ В фоне: запускаем регенерацию страницы
→ Кеш обновляется
Запрос 4 (после регенерации):
→ Отдаём из кеша: быстро ⚡ + свежие данные ✅
// ВАЖНО: ISR не гарантирует мгновенное обновление!
// Первый запрос ПОСЛЕ истечения revalidate получит старые данные
// + запустит регенерацию в фоне
// Следующий запрос получит свежие данные
// Если нужно мгновенное обновление — используй on-demand revalidation:
'use server';
export async function updateBlogPost(data: UpdatePostData) {
await db.post.update({ where: { id: data.id }, data });
revalidateTag('posts'); // Мгновенный сброс кеша!
revalidatePath(`/blog/${data.slug}`);
}

Временная ревалидацияТеговая ревалидация
Какrevalidate = NrevalidateTag() / revalidatePath()
КогдаАвтоматически по таймеруВручную (при изменении данных)
Гарантия свежестиМаксимум N секундПочти мгновенно
СложностьПростаяТребует webhook/action
Лучше дляДанные меняются регулярноДанные меняются редко/непредсказуемо

app/shop/page.tsx
// Паттерн 1: Разные revalidate для разных частей
export default async function ShopPage() {
const [categories, featuredProducts, banners] = await Promise.all([
// Категории — редко меняются
fetch('/api/categories', { next: { revalidate: 86400 } }).then(r => r.json()),
// Рекомендованные — раз в час
fetch('/api/featured', { next: { revalidate: 3600 } }).then(r => r.json()),
// Баннеры — всегда свежие (CMS driven)
fetch('/api/banners', { next: { tags: ['banners'] } }).then(r => r.json()),
]);
return <ShopLayout categories={categories} featured={featuredProducts} banners={banners} />;
}
// Паттерн 2: ISR + Suspense для долгих регенераций
// app/analytics/page.tsx
export const revalidate = 60;
export default function AnalyticsPage() {
return (
<div>
<h1>Аналитика</h1>
<Suspense fallback={<ChartSkeleton />}>
<SlowAnalyticsChart /> {/* Может быть медленным при регенерации */}
</Suspense>
</div>
);
}

  1. ISR = SSG + автоматическое обновление
  2. revalidate = N — обновлять каждые N секунд
  3. revalidateTag(tag) — мгновенный сброс кеша по тегу
  4. revalidatePath(path) — сброс по маршруту
  5. Stale-while-revalidate — пользователь получает быстрый ответ, обновление в фоне
  6. unstable_cache — кеш для ORM/не-fetch данных