Разработка ISR (Incremental Static Regeneration) для сайта
Incremental Static Regeneration — это SSG с возможностью обновления отдельных страниц без полной пересборки сайта. Страница генерируется один раз, кэшируется, а при устаревании кэша — регенерируется в фоне при следующем запросе. Пользователь всегда получает ответ мгновенно из кэша, а свежесть контента гарантируется TTL.
ISR занимает нишу между SSG (мгновенная отдача, устаревший контент) и SSR (свежий контент, латентность сервера при каждом запросе).
Как работает ISR
Классическая модель (Stale-While-Revalidate на уровне страниц):
- Первый запрос к странице — рендер на сервере, кэширование HTML
- Повторные запросы в течение TTL — отдача из кэша, ответ за < 10ms
- Запрос после истечения TTL — отдача устаревшего кэша (пользователь не ждёт), запуск фоновой регенерации
- Следующий запрос — свежий HTML из обновлённого кэша
Результат: TTFB как у статики, свежесть контента как у SSR.
Реализация в Next.js App Router
// app/products/[id]/page.tsx
interface Props {
params: { id: string };
}
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 300, // Регенерация не чаще раза в 5 минут
tags: [`product-${id}`], // Тег для on-demand revalidation
},
});
if (!res.ok) return null;
return res.json();
}
export default async function ProductPage({ params }: Props) {
const product = await getProduct(params.id);
if (!product) notFound();
return <ProductView product={product} />;
}
// Предварительная генерация популярных страниц при сборке
export async function generateStaticParams() {
const popularProducts = await fetch('https://api.example.com/products?popular=true&limit=100')
.then(r => r.json());
return popularProducts.map(({ id }: { id: string }) => ({ id }));
}
Страницы из generateStaticParams генерируются при сборке. Все остальные — при первом запросе (on-demand) и затем регенерируются по TTL.
On-Demand Revalidation — немедленное обновление
TTL-кэш не подходит, когда нужно обновить страницу сразу после изменения в CMS или базе. Для этого — on-demand revalidation через API:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tag, path } = await request.json();
if (tag) {
revalidateTag(tag); // Инвалидировать всё с этим тегом
}
if (path) {
revalidatePath(path); // Инвалидировать конкретный путь
}
return NextResponse.json({ revalidated: true, at: new Date().toISOString() });
}
Webhook из CMS вызывает этот endpoint при публикации:
# Пример запроса из Contentful webhook
curl -X POST https://example.com/api/revalidate \
-H "x-revalidate-secret: ${REVALIDATE_SECRET}" \
-H "Content-Type: application/json" \
-d '{"tag": "product-42"}'
Реализация в Nuxt 3
<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute();
// Nuxt cachedFetch с TTL
const { data: product } = await useFetch<Product>(
`/api/products/${route.params.id}`,
{
key: `product-${route.params.id}`,
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
}
);
// server/api/products/[id].ts использует cache() из nitro
</script>
// server/api/products/[id].ts
import { defineEventHandler, getRouterParam } from 'h3';
export default cachedEventHandler(
async (event) => {
const id = getRouterParam(event, 'id');
return await $fetch(`https://api.example.com/products/${id}`);
},
{
maxAge: 300,
staleMaxAge: 3600,
name: 'product',
getKey: (event) => `product-${getRouterParam(event, 'id')}`,
}
);
Стратегии кэширования
ISR позволяет задавать разные TTL для разных типов страниц:
| Тип страницы | TTL | Логика |
|---|---|---|
| Главная страница | 60 сек | Обновляется часто |
| Страница категории | 300 сек | Меняется при добавлении товаров |
| Страница товара | 3600 сек | Данные стабильные, цена — отдельный запрос |
| Статья блога | 86400 сек | Редко редактируется |
| Документация | On-demand only | Только при публикации |
Кэш-хранилище для распределённого деплоя
Vercel использует edge-кэш встроенно. Для self-hosted Next.js нужен внешний кэш:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheHandler: process.env.NODE_ENV === 'production'
? require.resolve('./cache-handler.js')
: undefined,
cacheMaxMemorySize: 0, // Отключить in-memory кэш при внешнем handler
};
// cache-handler.js — Redis-бэкэнд
const redis = require('ioredis');
const client = new redis(process.env.REDIS_URL);
module.exports = class CacheHandler {
async get(key) {
const data = await client.get(key);
return data ? JSON.parse(data) : null;
}
async set(key, data, ctx) {
const ttl = ctx.revalidate || 3600;
await client.setex(key, ttl, JSON.stringify({ value: data, lastModified: Date.now() }));
}
async revalidateTag(tag) {
// Сканирование и удаление ключей с тегом
const keys = await client.smembers(`tag:${tag}`);
if (keys.length) await client.del(...keys);
await client.del(`tag:${tag}`);
}
};
Fallback-стратегии для новых страниц
Для маршрутов, не сгенерированных при сборке, три варианта поведения:
// Next.js: dynamicParams контролирует поведение
export const dynamicParams = true; // Генерировать on-demand (по умолчанию)
// export const dynamicParams = false; // 404 для несгенерированных путей
// Nuxt: routeRules в nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/products/**': { isr: 300 }, // ISR с TTL 5 минут
'/blog/**': { isr: true }, // ISR только on-demand
'/dashboard/**': { ssr: true }, // Чистый SSR без кэша
'/static/**': { prerender: true }, // Только SSG
},
});
Мониторинг и отладка
Отслеживайте в production:
- Cache HIT rate — отношение ответов из кэша к регенерациям
- Revalidation duration — время фоновой регенерации (не должно превышать TTL)
- Stale responses — количество ответов с устаревшим контентом
// middleware.ts — логирование cache status
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set('x-cache-time', new Date().toISOString());
return response;
}
Сроки реализации
- Неделя 1–2: выбор стека, базовая SSG/SSR структура, TTL-стратегии по типам страниц
- Неделя 3: on-demand revalidation, webhook-интеграция с CMS
- Неделя 4: Redis cache handler для self-hosted, мониторинг cache hit rate
- Неделя 5: нагрузочное тестирование, оптимизация fallback-страниц, документация для контент-команды
- Неделя 6: деплой, настройка CI/CD с прогревом кэша после сборки







