Як працює інкрементальна статична регенерація (ISR) у Next.js
ISR (Інкрементальна статична регенерація) - стратегія Next.js, яка будує сторінки статично при деплої, а потім оновлює окремі сторінки у фоні після спливання таймера revalidate, без перезбірки всього застосунку.
Теорія
TL;DR
- Аналогія: кав'ярня з готовими чайниками. Чайник стоїть на прилавку (статика), але коли він спустошується, машина автоматично заварює новий. Ти берешь те, що є; наступний клієнт отримує свіже.
- Головна різниця: SSG потребує повної
next buildщоб оновити вміст. ISR оновлює окремі сторінки за таймером без перезбірки. SSR рендерить на кожен запит; ISR віддає з кешу. - Правило вибору: дані змінюються кожні кілька хвилин або годин і більшість запитів йде на одні й ті ж сторінки - ISR підходить. Дані персональні - SSR. Ніколи не змінюються - чистий SSG.
- Перший запит після спливання таймера отримує застарілу версію. Наступний - вже свіжу.
- Реvalідація за подією (
revalidatePath,revalidateTag) точніша за таймер: оновлюєш кеш саме тоді, коли дані змінились.
Швидкий приклад
// pages/posts.js - базовий ISR, Next.js 12+
export async function getStaticProps() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return {
props: { posts },
revalidate: 60 // регенерувати не частіше одного разу за 60 секунд
};
}
export default function Posts({ posts }) {
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// t=0: статичний HTML на CDN.
// t=61с, запит 1: отримує застарілу HTML, getStaticProps стартує у фоні.
// t=61с, запит 2: отримує щойно згенеровану сторінку.Поле revalidate: 60 серіалізується у статичний JSON payload під час збірки. Next.js зчитує його при кожній перевірці кешу і вирішує, чи ставити регенерацію в чергу.
Ключова різниця
ISR - це SSG з вбудованим таймером спливання. При збірці getStaticProps відпрацьовує звично і Next.js записує /_next/static/[page].json та HTML на диск або CDN. Різниця в тому, що цей JSON також зберігає значення revalidate. Коли запит приходить після спливання таймера, Node.js ставить у чергу фоновий виклик getStaticProps, атомарно перезаписує HTML та JSON через fs.promises, і продовжує віддавати застарілу версію поки запис не завершиться. Без простою, без повної перезбірки, жоден користувач не чекає.
Коли використовувати
- Дані оновлюються раз на годину або раз на день (статті, каталоги продуктів, документація) - ISR замість SSG
- Високий трафік, рідкі зміни - ISR замість SSR (немає витрат на рендер при кожному запиті)
- Дані в реальному часі або прив'язані до конкретного користувача - SSR
- Контент, який ніколи не змінюється - чистий SSG, ISR тут нічого не додає
- Часті зміни плюс персоналізація - SSR або клієнтський фетч
Порівняльна таблиця
| SSG | ISR | SSR | |
|---|---|---|---|
| Генерація | При збірці | При збірці + фон | Кожен запит |
| Перший завантаж | Миттєво (CDN) | Миттєво (CDN) | ~100-500мс на сервері |
| Оновлення | Повна next build | Фоново за таймером | Кожен запит |
| Застарілі відповіді | Ніколи | Так, до завершення регенерації | Ніколи |
| Коли | Документація, маркетинг | Новини, каталоги, дашборди | Профілі, кошики |
Як Next.js обробляє регенерацію всередині
При збірці getStaticProps серіалізує пропси в /_next/static/[page].json з полем revalidate і розгортає HTML на CDN. Коли приходить запит після спливання таймера (перевіряється через заголовки Vercel Edge або мітку часу Node-кешу), Next.js виконує чотири кроки по порядку:
- Відразу віддає застарілу сторінку поточному користувачу
- Ставить
getStaticPropsу чергу воркер-потоку - Атомарно записує новий JSON та HTML на файлову систему або CDN
- Всі паралельні запити під час регенерації також отримують застарілу версію
Якщо getStaticProps падає під час фонової регенерації, Next.js логує помилку і продовжує віддавати останню вдалу версію. Сторінка залишається доступною.
ISR у App Router
В App Router параметр revalidate переходить безпосередньо у виклик fetch:
// app/problems/page.tsx
export default async function ProblemsPage() {
const res = await fetch('https://api.itlead.org/problems', {
next: { revalidate: 300 } // вікно кешування 5 хвилин
});
const problems = await res.json();
return (
<ul>
{problems.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Або на рівні сегмента маршруту через експортовану константу:
// app/problems/layout.tsx
export const revalidate = 300;
export default function ProblemsLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}Для оновлення за подією, а не за таймером, використовуй revalidatePath або revalidateTag. На практиці цей підхід зручніший: інвалідуєш кеш саме тоді, коли CMS надіслав webhook, а не чекаєш на фіксоване вікно.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { secret, path } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Невірний секрет' }, { status: 401 });
}
revalidatePath(path);
return NextResponse.json({ revalidated: true });
}Тег-based інвалідація дозволяє скидати кеш одразу для кількох сторінок:
// Тегуємо fetch
const res = await fetch('https://api.itlead.org/problems', {
next: { tags: ['problems'] }
});
// Інвалідуємо всі сторінки, що використовували цей тег
revalidateTag('problems');Типові помилки
revalidate: 0 в розрахунку отримати поведінку SSR
// Неправильно - це не getServerSideProps
return { props: { data }, revalidate: 0 };revalidate: 0 не дає рендеринг на кожен запит. Сторінка залишається статичною і намагається регенеруватися при кожному запиті, але все одно підпадає під кешування. Для справжнього per-request рендерингу використовуй getServerSideProps.
Зміна стану на рівні модуля всередині getStaticProps
// Неправильно
let cache: string[] = [];
export async function getStaticProps() {
cache.push(await fetchData()); // стан втрачається між викликами Lambda
}getStaticProps запускається в ізольованих Lambda або VM-контекстах. Змінні рівня модуля не зберігаються між викликами. Для спільного стану використовуй Redis, базу даних або unstable_cache() у Next.js 14+.
Ігнорування стрибків TTFB від fallback: 'blocking' під навантаженням
export async function getStaticPaths() {
return { paths: [{ params: { slug: 'post1' } }], fallback: 'blocking' };
}З fallback: 'blocking' невідомі slug-и рендеряться на сервері при першому запиті, потім кешуються як ISR. Цей перший рендер блокуючий і послідовний. При раптових сплесках трафіку для нових slug-ів p99 TTFB помітно зростає. Попередньо збирай популярні шляхи або використовуй fallback: true зі станом завантаження.
getStaticProps що виконується довше 10 секунд
Vercel обриває виконання після 10 секунд як під час збірки, так і в продакшені. Якщо CMS повертає 500 елементів - пагінуй запити. Для великих сайтів розділи фетчі на менші шматки.
Preview mode без явного revalidate: false
Коли активний preview mode, таймер revalidate ігнорується, але чернетки можуть потрапити в CDN-кеш і з'явитися у реальних користувачів. Завжди повертай revalidate: false явно:
if (preview) return { props: { draftData }, revalidate: false };Де зустрічається в реальних проєктах
- Блог Vercel:
revalidate: 60, статті оновлюються без деплою - Документація Stripe: changelog продуктів через Git-based CMS,
revalidate: 3600 - Hashnode: стрічки блогів через ISR, нові сторінки авторів через
fallback: blocking - Блог Лі Робінсона: список доповідей з Airtable, оновлюється через ISR
- TinaCMS + Next.js starter: ISR у поєднанні з preview mode для headless CMS
Питання на співбесіді
Q: Як ISR обробляє паралельні запити під час регенерації?
A: Всі запити під час регенерації отримують застарілу сторінку. Next.js використовує атомарний fs.writeFile та перевірку ETag щоб уникнути race conditions. Трафік перемикається на нову версію тільки після завершення запису.
Q: Яка різниця між revalidate: 60 у getStaticProps і revalidatePath()?
A: revalidate - це тригер за часом: сторінка регенерується не частіше одного разу за N секунд після першого запиту після спливання. revalidatePath('/path') - це ручний виклик: ти запускаєш його з Route Handler або Server Action, і кеш скидається одразу незалежно від таймера.
Q: Що станеться, якщо getStaticProps падає під час фонової регенерації?
A: Next.js логує помилку і продовжує віддавати останню вдалу застарілу версію. Повернення до SSR не відбувається. Сторінка залишається доступною до першої успішної регенерації.
Q: ISR однаково працює на self-hosted Node і на Vercel?
A: Механізм той самий, але self-hosted потребує постійної файлової системи або кастомного cache handler для розподілених інстансів. Vercel автоматично масштабує воркери і ділить кеш між інстансами через Edge Network без додаткових налаштувань.
Q (senior): В App Router (Next.js 14+), як { next: { revalidate: 60 } } у fetch відрізняється від revalidatePath? Які шари кешу задіяні?
A: fetch з revalidate впливає на Data Cache - серверний кеш відповідей окремих запитів. revalidatePath інвалідує Full Route Cache (відрендерений RSC payload та HTML) і очищає Router Cache на клієнті. Якщо викликати тільки revalidatePath, старі відповіді fetch залишаться у Data Cache до спливання їх власного таймера. Для повної свіжості зазвичай потрібно обидва: revalidateTag для Data Cache і revalidatePath для Route Cache.
Q (senior): Як масштабувати ISR на 10 000 сторінок з різними інтервалами revalidate?
A: Не збирай всі 10к при деплої. Використовуй динамічні сегменти з fallback: 'blocking', щоб сторінки будувались при першому запиті і кешувались як ISR. Встановлюй коротший revalidate для high-traffic маршрутів і довший для хвоста. На self-hosted налаштуй Redis-based cache handler щоб уникнути конкуренції за файлову систему між кількома Node-інстансами.
Приклади
Базовий: список статей з таймерним ISR (Pages Router)
// pages/posts.tsx
interface Post { id: number; title: string; }
export async function getStaticProps() {
const posts: Post[] = await fetch('https://api.example.com/posts')
.then(r => r.json());
return {
props: { posts },
revalidate: 60
};
}
export default function Posts({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
// t=0: статичний HTML на CDN.
// t=61с запит 1: застаріла HTML, фонова регенерація стартує.
// t=61с запит 2: щойно згенерована сторінка.revalidate: 60 у return - це весь контракт ISR з Next.js. Все інше - стандартний getStaticProps.
Середній: каталог продуктів з Contentful з 5-хвилинною реvalідацією і безпечним preview mode
// pages/products.tsx
import { createClient } from 'contentful';
interface Product {
sys: { id: string };
fields: { name: string; price: number };
}
export async function getStaticProps({ preview = false }: { preview?: boolean }) {
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: preview
? process.env.CONTENTFUL_PREVIEW_TOKEN!
: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
const entries = await client.getEntries<any>({
content_type: 'product',
limit: 20,
});
// Preview mode: ніколи не кешуємо чернетки
if (preview) {
return { props: { products: entries.items }, revalidate: false };
}
return {
props: { products: entries.items },
revalidate: 300 // 5 хвилин покриває більшість циклів оновлення цін
};
}
export default function Products({ products }: { products: Product[] }) {
return (
<div>
{products.map(p => (
<div key={p.sys.id}>
<h2>{p.fields.name}</h2>
<p>${p.fields.price}</p>
</div>
))}
</div>
);
}Без revalidate: false у preview mode чернетки можуть потрапити в CDN-кеш і показатися реальним користувачам. Цей патерн легко пропустити, і він зустрічається у продакшн-інцидентах частіше, ніж здається.
Просунутий: динамічні slug-и з fallback: 'blocking' та webhook-driven реvalідацією (App Router)
// app/articles/[slug]/page.tsx
interface Article { slug: string; title: string; body: string; }
// При деплої будуємо тільки топ-10; невідомі slug-и рендеряться при першому запиті
export async function generateStaticParams() {
const articles: Article[] = await fetch(
'https://api.example.com/articles?limit=10'
).then(r => r.json());
return articles.map(a => ({ slug: a.slug }));
}
export const dynamicParams = true; // дозволяємо невідомі slug-и
export const revalidate = 10; // таймер як запасний варіант
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article: Article = await fetch(
`https://api.example.com/articles/${params.slug}`,
{ next: { tags: [`article-${params.slug}`] } } // тег для точкової інвалідації
).then(r => r.json());
return (
<article>
<h1>{article.title}</h1>
<p>{article.body}</p>
</article>
);
}// app/api/revalidate/route.ts - викликається з CMS webhook при публікації
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { secret, slug } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(`article-${slug}`);
return NextResponse.json({ revalidated: true, slug });
}Перший запит для невідомого slug-а блокується (як SSR), а потім кешується як ISR-сторінка. Після цього CMS надсилає webhook при публікації і revalidateTag скидає кеш одразу. Таймер 10 секунд спрацьовує як запасний механізм якщо webhook не дійшов. Обидва підходи разом дають оновлення в реальному часі без відмови від статичної швидкості для відомих сторінок.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.