Skip to main content

Стратегії повторної валідації в Next.js

Revalidation (повторна валідація) в Next.js оновлює кешовані сторінки свіжими даними без повного перебудови сайту.

Теорія

TL;DR

  • Аналогія: кав'ярня з готовою кавою в термосах. Клієнти завжди отримують каву з термоса (кеш), але щогодини хтось паралельно заварює нову порцію, поки стара ще роздається.
  • Time-based revalidation оновлює за розкладом (stale-while-revalidate); on-demand revalidation інвалідує кеш одразу при зміні даних.
  • Правило вибору: time-based для контенту, який рідко змінюється (блог, документація); on-demand там, де застарілі дані справді шкодять (ціни, залишки товарів).
  • revalidateTag дає точковий контроль: один тег може покривати кілька fetch-запитів на різних маршрутах.

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

tsx
// app/products/page.tsx (App Router) async function getProducts() { const res = await fetch('https://api.example.com/products', { next: { revalidate: 3600 } // Кеш на 1 годину, потім фоновий fetch }); return res.json(); } export default async function ProductsPage() { const products = await getProducts(); // Перший візит: завантажує живі дані, зберігає в кеш // Візити протягом 1 години: з кешу (~50ms) // Перший візит після 1 години: отримує кешовану версію, // але запускає фоновий fetch для наступного відвідувача return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>; }

Останній момент найчастіше пропускають. Користувач, який тригерує revalidation, все одно бачить застарілі дані. Свіжу версію отримає вже наступний відвідувач.

Головна різниця

Time-based і on-demand revalidation вирішують різні задачі. Time-based реалізує stale-while-revalidate: сторінка віддається з кешу миттєво, а Next.js запускає фоновий процес для регенерації після закінчення TTL. On-demand взагалі ігнорує таймер. Webhook або server action викликає revalidatePath() чи revalidateTag(), і кеш інвалідується одразу. Ніякого очікування.

Коли використовувати

  • Блог, документація, SEO-сторінки: revalidate: 86400 (щодня). Стаття добової давності - нормально.
  • Каталог е-комерсу: revalidate: 300 (5 хвилин) як запасний варіант плюс revalidateTag('products') з Shopify/Stripe webhook для миттєвих оновлень.
  • Актуальні курси, біржові котирування, рахунки в грі: cache: 'no-store' або export const dynamic = 'force-dynamic'. Кешувати не потрібно взагалі.
  • Дані конкретного користувача (дашборди, профілі): noStore() або динамічний рендеринг на кожен запит. ISR тут не підходить.
  • Великі сайти з пов'язаним контентом (кошик + список товарів): tag-based revalidation з revalidateTag('cart'), щоб оновлювати тільки те, що змінилось.

Таблиця порівняння

СтратегіяТригерДопустима застарілістьДе використовуватиВплив на затримку
Time-based (ISR)Таймер (revalidate: 3600)До закінчення інтервалуБлоги, SEO-сторінкиНемає (stale-while-revalidate)
On-demand pathrevalidatePath('/products')Нуль після тригераCMS, мутаціїНемає (фоново)
On-demand tagrevalidateTag('products')Нуль після тригераЕ-комерс, точкові оновленняНемає (фоново)
Per-request dynamicНа кожен запитНемаєДашборди, дані юзераВисока (завжди fetch)
Без кешуcache: 'no-store'НемаєReal-time даніВисока (завжди fetch)
Коли використовуватиРідкі зміни → time-based; терміново → on-demand; дані юзера → dynamic

Як це працює всередині

При першому запиті Next.js генерує сторінку і зберігає результат (HTML + JSON) в .next/cache на диску або в розподіленому кеші Vercel. Наступні запити перевіряють, чи не закінчився TTL. Якщо ні, кешована відповідь іде миттєво. Якщо TTL вийшов, застаріла відповідь все одно іде миттєво (це і є stale-while-revalidate), але Next.js запускає фоновий Node.js worker для повторного fetch і регенерації сторінки. Після завершення кеш атомарно замінюється, і наступний запит отримає свіжу версію.

On-demand revalidation взагалі не дивиться на TTL. revalidatePath або revalidateTag позначають конкретні записи в кеші як застарілі прямо на сервері. Наступний запит до цього шляху запустить свіжий fetch, не плановий.

Важливий edge case: Router Cache (кеш оболонок сторінок у браузері) і Data Cache (серверний) - це різні рівні. revalidatePath('/products') інвалідує Data Cache, але Router Cache в браузері може зберігатись ще до 30 секунд у Next.js 14. Якщо потрібно оновити і layout теж, використовуй revalidatePath('/', 'layout').

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

1. Поєднання revalidate з динамічним рендерингом

tsx
// Неправильно: searchParams вмикає динамічний рендеринг, revalidate ігнорується export const revalidate = 3600; export default function Page({ searchParams }: any) { // searchParams робить сторінку динамічною // revalidate тут нічого не робить const query = searchParams.q; return <Results query={query} />; }

В Next.js 14+ searchParams переключає сторінку на динамічний рендеринг. Експорт revalidate при цьому ігнорується. Потрібно або прибрати revalidate і прийняти динамічну поведінку, або явно додати noStore().

2. Очікування фонового оновлення без трафіку

tsx
fetch(url, { next: { revalidate: 3600 } }); // Якщо після закінчення TTL ніхто не зайде на сторінку, // кеш так і залишиться застарілим назавжди. // Фонового крону немає - revalidation потребує вхідного запиту.

На сторінках з низьким трафіком додай cron-джоб, який регулярно звертається до URL, або переходь на on-demand revalidation через webhook.

3. Плутанина між Router Cache і Data Cache

tsx
// Після revalidatePath('/') Data Cache оновлюється. // Але Router Cache (оболонка сторінки в браузері) може ще бути застарілою. // Рішення: revalidatePath('/', 'layout'); // Проходить через всі сегменти layout

4. revalidateTag на fetch без тегів

tsx
// Цей fetch без тегу - revalidateTag його не зачепить fetch(url, { next: { revalidate: 60 } }); // Далі: revalidateTag('products'); // Нічого не робить для fetch вище

Завжди додавай { tags: ['products'] } до fetch-запитів, якими хочеш керувати через revalidateTag. Time-based і tag-based опції для одного fetch є взаємовиключними.

Де зустрічається в реальних проєктах

  • Vercel Commerce (Next.js Commerce): on-demand webhook від Shopify/Stripe тригерить revalidateTag('products') при зміні товарів.
  • TinaCMS: time-based ISR з revalidate: 10 секунд для markdown-контенту, зміни в редакторі з'являються практично одразу без перебудови.
  • Supabase + Next.js: revalidateTag(`user-${id}`) після зміни стану авторизації.
  • Medusa.js: гібридний підхід - time-based для каталогу, on-demand для статусів замовлень.
  • Для порівняння: revalidation замість dynamic рендерингу дає перевагу там, де трафік великий і дані напівстатичні. Різниця між кешованою відповіддю (до 50ms) і динамічним fetch (200-500ms+) відчутна при навантаженні.

Питання на співбесіді

Q: Яка різниця між stale-while-revalidate в Next.js і директивою stale-while-revalidate в HTTP Cache-Control?
A: В Next.js це серверна поведінка: Next.js роздає кешовану сторінку і паралельно регенерує її через Node.js worker. HTTP-директива робить те саме, але на рівні CDN чи браузера (наприклад, Cloudflare). Вони можуть працювати разом: Next.js оновлює серверний кеш, CDN тим часом роздає свою застарілу копію.

Q: Чим відрізняється revalidation в App Router від Pages Router?
A: Pages Router має один ISR-запис на шлях без підтримки тегів. App Router додає окремі рівні Data Cache і Router Cache, підтримує revalidateTag для точкової інвалідації між кількома маршрутами і дозволяє revalidation на рівні сегментів layout.

Q: Що відбувається при виклику revalidatePath під час Server Action?
A: Next.js позначає кеш-запис шляху як застарілий одразу. Але сама revalidation відбувається після завершення Server Action, не під час. Спочатку іде відповідь, потім чиститься кеш.

Q: (Senior) У вкладеному layout зі спільними даними між сегментами - як оновити лічильник кошика в хедері без перефетчу всього?
A: Позначити fetch в layout-компоненті тегом { tags: ['cart'] }. Після мутації кошика в Server Action або webhook викликати revalidateTag('cart'). Тільки теговані fetches регенеруються, решта layout залишається в кеші. Це краще ніж revalidatePath('/', 'layout'), який інвалідує все підряд.

Приклади

Базовий: time-based revalidation для блогу

tsx
// app/blog/page.tsx async function getPosts() { const res = await fetch('https://cms.example.com/posts', { next: { revalidate: 86400 } // Оновлення раз на день }); return res.json(); } export default async function BlogPage() { const posts = await getPosts(); return ( <ul> {posts.map((post: { id: string; title: string }) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }

Стаття добової давності цілком нормально. Сторінка завантажується за 50ms з кешу, читачі не помічають фонової регенерації.

Середній: on-demand revalidation через Stripe webhook

tsx
// app/shop/products/page.tsx async function getProducts() { const res = await fetch('https://api.stripe.com/v1/products?limit=10', { next: { tags: ['featured-products'] } // Без таймера - оновлюється тільки коли прийде webhook }); return res.json(); } export default async function ProductsPage() { const { data: products } = await getProducts(); return products.map((p: { id: string; name: string }) => ( <div key={p.id}>{p.name}</div> )); }
tsx
// app/api/revalidate/route.ts import { revalidateTag } from 'next/cache'; export async function POST(request: Request) { // В продакшені: тут перевіряти підпис Stripe webhook revalidateTag('featured-products'); return Response.json({ revalidated: true, timestamp: Date.now() }); }

Коли Stripe надсилає подію product.updated, webhook вдаряє по /api/revalidate, тег інвалідується, і наступний відвідувач /shop/products отримує свіжі дані. Всі до цього моменту бачать кеш. Це очікувана поведінка.

Просунутий: Server Action з tag-based revalidation

tsx
// actions/posts.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function createPost(formData: FormData) { const title = formData.get('title') as string; await db.insert(posts).values({ title }); // Інвалідує всі fetches з тегом 'posts' на всіх маршрутах revalidateTag('posts'); }
tsx
// app/blog/new/page.tsx 'use client'; import { createPost } from '@/actions/posts'; export function NewPostForm() { return ( <form action={createPost}> <input name="title" placeholder="Назва поста" required /> <button type="submit">Опублікувати</button> </form> ); }

Після відправки форми revalidateTag('posts') спрацьовує, і всі маршрути з { tags: ['posts'] } отримають свіжі дані при наступному запиті. Окремий API route не потрібен: Server Actions обробляють revalidation напряму.

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

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

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

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