Главная/Статьи/ISR в NextJS — кэширование на стероидах

ISR в NextJS — кэширование на стероидах

Incremental Static Regeneration позволяет обновлять статические страницы без полной пересборки. Разбираем revalidate, on-demand ISR и типичные ошибки.

ДМ
Дмитрий Мещеряков
📅 24 августа 2026 г.📖 8 мин чтения

ISR — компромисс между статикой и рендерингом на каждый запрос, и как всякий компромисс, он полезен ровно до тех пор, пока вы понимаете его цену. Главное, что нужно усвоить сразу: первый посетитель после истечения срока получает устаревшую страницу. Не «может получить» — получит, всегда. Это заложено в механику, а не является недостатком реализации.

Дальше — как этим пользоваться и где ISR применять не стоит.

Проблема: статика vs динамика

В NextJS есть два подхода к рендерингу:

  • SSG (Static Site Generation) — страницы генерируются при сборке. Быстро, но данные устаревают.
  • SSR (Server-Side Rendering) — страницы генерируются при каждом запросе. Актуально, но нагружает сервер.

ISR — это гибрид: страница статическая, но умеет обновляться в фоне.

💡 Совет

Когда использовать ISR: каталоги товаров, блоги, лендинги с редко меняющимся контентом, страницы из CMS. Не подходит для персонализированного контента и real-time данных.

Базовый ISR: revalidate

Самый простой способ — указать revalidate в секундах:

typescript
// 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>
  );
}

Как это работает

  1. Первый посетитель получает статическую страницу из кэша
  2. Через 60 секунд следующий запрос триггерит фоновую регенерацию
  3. Пока страница генерируется, посетители видят старую версию
  4. Когда готово — новая версия заменяет старую
text
Запрос в 0:00  → Страница v1 (из кэша)
Запрос в 0:30  → Страница v1 (из кэша)
Запрос в 1:01  → Страница v1 (из кэша) + запуск регенерации в фоне
Запрос в 1:02  → Страница v2 (новая версия готова)

revalidate на уровне страницы

Можно задать revalidate для всей страницы через экспорт:

typescript
// 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 берёт минимальное значение:

typescript
// Страница будет ревалидироваться каждые 60 секунд (минимум)
const products = await fetch(url, { next: { revalidate: 60 } });
const categories = await fetch(url, { next: { revalidate: 3600 } });

On-Demand ISR: ревалидация по событию

Иногда ждать таймаут неприемлемо — нужно обновить страницу сразу после изменения данных.

Route Handler для ревалидации

typescript
// 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 }
  );
}

Использование тегов

Теги позволяют группировать связанные данные:

typescript
// 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

typescript
// 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:

typescript
// 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;

Паттерн: популярное статически, редкое — по запросу

typescript
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

typescript
// ❌ Это НЕ SSR — это отключение кэша fetch
export const revalidate = 0;
// ✅ Для SSR используйте:
export const dynamic = 'force-dynamic';

2. Забыли про layout

Layout тоже кэшируется! Если данные в layout, они не обновятся при ревалидации страницы:

typescript
// 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. Не учли зависимости

typescript
// Страница товара зависит от:
// - данных товара
// - связанных товаров
// - отзывов
// - наличия на складе
// Ревалидация только по product-${id} не обновит отзывы!
// Используйте несколько тегов:
next: { tags: ['products', `product-${id}`, `reviews-${id}`] }

4. Слишком агрессивная ревалидация

typescript
// ❌ Ревалидация всего сайта при любом изменении
revalidatePath('/', 'layout');
// ✅ Точечная ревалидация
revalidatePath(`/products/${slug}`);
revalidateTag(`product-${id}`);

Мониторинг ISR

Добавьте заголовки для отладки:

typescript
// 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.