Стратегії повторної валідації в Next.js
Revalidation (повторна валідація) в Next.js оновлює кешовані сторінки свіжими даними без повного перебудови сайту.
Теорія
TL;DR
- Аналогія: кав'ярня з готовою кавою в термосах. Клієнти завжди отримують каву з термоса (кеш), але щогодини хтось паралельно заварює нову порцію, поки стара ще роздається.
- Time-based revalidation оновлює за розкладом (stale-while-revalidate); on-demand revalidation інвалідує кеш одразу при зміні даних.
- Правило вибору: time-based для контенту, який рідко змінюється (блог, документація); on-demand там, де застарілі дані справді шкодять (ціни, залишки товарів).
revalidateTagдає точковий контроль: один тег може покривати кілька fetch-запитів на різних маршрутах.
Швидкий приклад
// 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 path | revalidatePath('/products') | Нуль після тригера | CMS, мутації | Немає (фоново) |
| On-demand tag | revalidateTag('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 з динамічним рендерингом
// Неправильно: 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. Очікування фонового оновлення без трафіку
fetch(url, { next: { revalidate: 3600 } });
// Якщо після закінчення TTL ніхто не зайде на сторінку,
// кеш так і залишиться застарілим назавжди.
// Фонового крону немає - revalidation потребує вхідного запиту.На сторінках з низьким трафіком додай cron-джоб, який регулярно звертається до URL, або переходь на on-demand revalidation через webhook.
3. Плутанина між Router Cache і Data Cache
// Після revalidatePath('/') Data Cache оновлюється.
// Але Router Cache (оболонка сторінки в браузері) може ще бути застарілою.
// Рішення:
revalidatePath('/', 'layout'); // Проходить через всі сегменти layout4. revalidateTag на fetch без тегів
// Цей 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 для блогу
// 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
// 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>
));
}// 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
// 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');
}// 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 напряму.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.