Skip to main content

Як працює інкрементальна статична регенерація (ISR) у Next.js

ISR (Інкрементальна статична регенерація) - стратегія Next.js, яка будує сторінки статично при деплої, а потім оновлює окремі сторінки у фоні після спливання таймера revalidate, без перезбірки всього застосунку.

Теорія

TL;DR

  • Аналогія: кав'ярня з готовими чайниками. Чайник стоїть на прилавку (статика), але коли він спустошується, машина автоматично заварює новий. Ти берешь те, що є; наступний клієнт отримує свіже.
  • Головна різниця: SSG потребує повної next build щоб оновити вміст. ISR оновлює окремі сторінки за таймером без перезбірки. SSR рендерить на кожен запит; ISR віддає з кешу.
  • Правило вибору: дані змінюються кожні кілька хвилин або годин і більшість запитів йде на одні й ті ж сторінки - ISR підходить. Дані персональні - SSR. Ніколи не змінюються - чистий SSG.
  • Перший запит після спливання таймера отримує застарілу версію. Наступний - вже свіжу.
  • Реvalідація за подією (revalidatePath, revalidateTag) точніша за таймер: оновлюєш кеш саме тоді, коли дані змінились.

Швидкий приклад

tsx
// 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 або клієнтський фетч

Порівняльна таблиця

SSGISRSSR
ГенераціяПри збірціПри збірці + фонКожен запит
Перший завантажМиттєво (CDN)Миттєво (CDN)~100-500мс на сервері
ОновленняПовна next buildФоново за таймеромКожен запит
Застарілі відповідіНіколиТак, до завершення регенераціїНіколи
КолиДокументація, маркетингНовини, каталоги, дашбордиПрофілі, кошики

Як Next.js обробляє регенерацію всередині

При збірці getStaticProps серіалізує пропси в /_next/static/[page].json з полем revalidate і розгортає HTML на CDN. Коли приходить запит після спливання таймера (перевіряється через заголовки Vercel Edge або мітку часу Node-кешу), Next.js виконує чотири кроки по порядку:

  1. Відразу віддає застарілу сторінку поточному користувачу
  2. Ставить getStaticProps у чергу воркер-потоку
  3. Атомарно записує новий JSON та HTML на файлову систему або CDN
  4. Всі паралельні запити під час регенерації також отримують застарілу версію

Якщо getStaticProps падає під час фонової регенерації, Next.js логує помилку і продовжує віддавати останню вдалу версію. Сторінка залишається доступною.

ISR у App Router

В App Router параметр revalidate переходить безпосередньо у виклик fetch:

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

Або на рівні сегмента маршруту через експортовану константу:

tsx
// app/problems/layout.tsx export const revalidate = 300; export default function ProblemsLayout({ children }: { children: React.ReactNode }) { return <>{children}</>; }

Для оновлення за подією, а не за таймером, використовуй revalidatePath або revalidateTag. На практиці цей підхід зручніший: інвалідуєш кеш саме тоді, коли CMS надіслав webhook, а не чекаєш на фіксоване вікно.

tsx
// 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 інвалідація дозволяє скидати кеш одразу для кількох сторінок:

tsx
// Тегуємо fetch const res = await fetch('https://api.itlead.org/problems', { next: { tags: ['problems'] } }); // Інвалідуємо всі сторінки, що використовували цей тег revalidateTag('problems');

Типові помилки

revalidate: 0 в розрахунку отримати поведінку SSR

tsx
// Неправильно - це не getServerSideProps return { props: { data }, revalidate: 0 };

revalidate: 0 не дає рендеринг на кожен запит. Сторінка залишається статичною і намагається регенеруватися при кожному запиті, але все одно підпадає під кешування. Для справжнього per-request рендерингу використовуй getServerSideProps.

Зміна стану на рівні модуля всередині getStaticProps

tsx
// Неправильно let cache: string[] = []; export async function getStaticProps() { cache.push(await fetchData()); // стан втрачається між викликами Lambda }

getStaticProps запускається в ізольованих Lambda або VM-контекстах. Змінні рівня модуля не зберігаються між викликами. Для спільного стану використовуй Redis, базу даних або unstable_cache() у Next.js 14+.

Ігнорування стрибків TTFB від fallback: 'blocking' під навантаженням

tsx
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 явно:

tsx
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)

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

tsx
// 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)

tsx
// 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> ); }
tsx
// 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 не дійшов. Обидва підходи разом дають оновлення в реальному часі без відмови від статичної швидкості для відомих сторінок.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?