Skip to main content

Кешування в Next.js

Кешування в Next.js зберігає результати запитів, згенерований HTML та RSC payload на чотирьох рівнях, щоб сторінки завантажувались швидше без жодного написаного вручну кешуючого коду.

Теорія

TL;DR

  • Чотири рівні: мемоізація запитів (дедуплікує під час одного рендеру), кеш даних (зберігається між запитами і деплойментами), кеш повного маршруту (зберігає HTML під час білду), кеш маршрутизатора (клієнтський, на час вкладки)
  • Аналогія: кухня ресторану з підготовчими станціями (мемоізація), холодильником (кеш даних), готовими стравами на роздачі (кеш маршруту) і нотатником офіціанта (кеш маршрутизатора)
  • Серверні кеші переживають деплойменти; кеш маршрутизатора зникає з закриттям вкладки
  • Правило вибору: cache: 'no-store' для живих даних, статичний режим за замовчуванням для блогів і каталогів
  • Головна пастка: fetch з no-store НЕ обходить кеш повного маршруту без dynamic = 'force-dynamic'

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

tsx
// app/posts/page.tsx // Два компоненти отримують дані з одного URL в одному рендері // Мережевий запит відбувається рівно один раз (мемоізація запитів) async function PostList() { const res = await fetch('https://api.example.com/posts', { cache: 'force-cache' }) const posts = await res.json() return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul> } export default async function Page() { return ( <> <PostList /> {/* fetch виконується тут */} <PostList /> {/* мережевий запит не йде, дані вже є */} </> ) }

Другий <PostList /> використовує вже отримані дані. Без додаткового HTTP-запиту. Це і є мемоізація запитів.

Чотири рівні кешування

Next.js 14+ стекує кеші в певному порядку. Розуміння того, який рівень спрацьовує першим, знімає більшість питань на співбесіді.

Мемоізація запитів працює під час одного серверного рендеру. React дедуплікує fetch-виклики з однаковим URL і опціями, зберігаючи результати в Map на час запиту. Після завершення рендеру вона скидається. Для Prisma або Drizzle, які не йдуть через fetch, використовуй React.cache() для такого ж дедублювання.

Кеш даних зберігається між запитами і між деплойментами. На Vercel він використовує глобально розподілене Edge KV-сховище; локально записується у файлову систему. Він переживає перезапуски сервера, поки не скинеш його явно через revalidateTag() або revalidatePath().

Кеш повного маршруту зберігає згенерований HTML та RSC payload для статичних маршрутів під час білду. Саме він робить статичну Next.js-сторінку миттєвою: сервер віддає файл, а не виконує обчислення. Маршрут залишається статичним, поки не викликаються динамічні функції (cookies(), headers(), searchParams) і всі запити кешуються.

Кеш маршрутизатора живе в браузері. Він зберігає RSC payload відвіданих маршрутів, роблячи повернення назад миттєвим. Також автоматично передзавантажує цілі <Link>. Термін дії спливає через 30 секунд застарілості або при закритті вкладки.

Ключова різниця: статичний та динамічний рендеринг

Статичний рендеринг: Next.js виконує сторінку під час білду (або при першому запиті для ISR), зберігає HTML і RSC payload у кеші повного маршруту і віддає цей файл для всіх наступних запитів. Підходить для блогів, сторінок товарів, маркетингових сайтів.

Динамічний рендеринг запускається при читанні cookies(), headers(), searchParams або при dynamic = 'force-dynamic'. Сервер повторно виконує дерево компонентів на кожен запит. Кеш повного маршруту не задіяний.

Але кеш даних все одно працює всередині динамічних маршрутів. Сторінка може рендеритись на кожен запит і при цьому повторно використовувати закешовані відповіді API. Це два незалежні перемикачі, і більшість команд поводиться з ними як з одним.

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

  • Статичний блог - стандартний fetch або next: { revalidate: 3600 } (кеш даних, ISR)
  • Сторінка товару в магазині - кеш повного маршруту при білді, revalidateTag('product') при зміні залишку
  • Дашборд користувача - cookies() або { cache: 'no-store' } для примусового динамічного рендеру
  • Адмін-панель - export const dynamic = 'force-dynamic' на рівні сторінки
  • Метрики в реальному часі - cache: 'no-store' разом з dynamic = 'force-dynamic'
  • A/B тест - no-store і кастомні заголовки для вибору варіанту на кожен запит

Як працює ревалідація

Два підходи. Часовий задає вікно застарілості:

tsx
const res = await fetch('https://api.itlead.org/problems', { next: { revalidate: 300 } // застаріє через 5 хвилин })

Подієвий через теги дозволяє скидати кеш на вимогу:

tsx
// При отриманні даних const res = await fetch(url, { next: { tags: ['problems'] } }) // В Server Action після запису import { revalidateTag } from 'next/cache' revalidateTag('problems')

export const revalidate = 3600 на рівні сторінки задає запасне значення для всіх запитів. Окремі fetch-виклики можуть перевизначити його власним значенням. Найменше значення визначає, коли сторінка перегенерується.

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

Помилка 1: no-store без force-dynamic

tsx
// Неправильно: HTML все одно кешується під час білду export default async function Page() { const data = await fetch('/api/live', { cache: 'no-store' }) return <div>{data.value}</div> } // Правильно: вказати Next.js що весь маршрут динамічний export const dynamic = 'force-dynamic' export default async function Page() { const data = await fetch('/api/live', { cache: 'no-store' }) return <div>{data.value}</div> }

fetch з no-store пропускає кеш даних. Але кеш повного маршруту все одно зберігає HTML під час білду. Потрібні обидва налаштування.

Помилка 2: revalidate: 0 в розрахунку що це скидає все

revalidate: 0 впливає лише на кеш даних. Мемоізація запитів і кеш повного маршруту все одно спрацьовують. Щоб повністю вийти з кешування, використовуй cache: 'no-store' разом з dynamic = 'force-dynamic'.

Помилка 3: застарілі дані після мутації через кеш маршрутизатора

Користувач відправляє форму, Server Action оновлює базу, але при наступній навігації показуються старі дані. Браузерний кеш маршрутизатора видав RSC payload до мутації. Виправлення:

tsx
import { revalidatePath } from 'next/cache' export async function updateProfile(formData: FormData) { 'use server' await db.user.update(...) revalidatePath('/profile') // скидає і серверний, і клієнтський кеш }

Або виклик router.refresh() на клієнті після мутації.

Помилка 4: різні опції fetch ламають мемоізацію

Мемоізація прив'язана до URL, опцій і тіла запиту. Два виклики до одного URL з різними значеннями cache вважаються двома окремими запитами. Стандартизуй опції між компонентами, що використовують одне джерело даних.

Помилка 5: очікування що next dev використовує кеш повного маршруту

Режим розробки повністю обходить кеш повного маршруту, щоб кожна зміна одразу відображалась. Одного разу витратив хвилин тридцять на відладку уявного "баґу кешування" в dev, поки не згадав: кеш повного маршруту існує лише в продакшені. Завжди перевіряй поведінку кешування через next build && next start.

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

  • Vercel Commerce (nextjs-commerce): кеш повного маршруту для сторінок товарів, кеш даних з revalidateTag для залишків
  • Supabase + Next.js стартери: revalidate: 60 для списків користувачів, no-store для сторінок редагування
  • Next.js дашборди з NextAuth: читання headers() примушує динамічний рендер для авторизованих маршрутів
  • Порівняно з React Query: Next.js кешування обробляє серверні запити без додаткового коду; React Query краще підходить для клієнтських мутацій та оптимістичних оновлень

Питання з продовженням

Q: Яка різниця між кешем даних і кешем повного маршруту?
A: Кеш даних зберігає сирі відповіді fetch (JSON) між запитами. Кеш повного маршруту зберігає згенерований HTML та RSC payload для всього статичного маршруту під час білду.

Q: Якщо встановити export const revalidate = 3600 на рівні сторінки, що відбувається з окремими fetch-викликами з власним revalidate?
A: Значення рівня сторінки є запасним. Будь-який fetch з власним next: { revalidate } перевизначає його для конкретного виклику. Найменше значення визначає, коли сторінка перегенерується.

Q: Хард-рефреш проти м'якої навігації: що змінюється?
A: М'яка навігація (клік на <Link>) спочатку звертається до кешу маршрутизатора. Хард-рефреш повністю пропускає його, йде на сервер, який перевіряє кеш повного маршруту і кеш даних.

Q: Чи читання searchParams завжди робить маршрут динамічним?
A: Так. Як тільки Next.js бачить звернення до searchParams, маршрут виходить з кешу повного маршруту. Сторінка рендериться на кожен запит, але кеш даних все одно працює для запитів всередині неї.

Q (senior): Edge runtime проти Node.js runtime: як поводиться кешування?
A: На Vercel Edge runtime використовує глобально розподілене KV-сховище, тому закешовані дані спільні між усіма регіонами. Node.js runtime використовує локальну файлову систему, тому кожен серверний інстанс має власний кеш. Це важливо для мультирегіональних деплойментів, де час інвалідації кешу відрізняється.

Q (senior): Чому next dev ніколи не показує кеш повного маршруту навіть для статичних сторінок?
A: Режим розробки навмисно обходить його, щоб кожна зміна одразу відображалась. Крок білду (next build) потрібен для заповнення і тестування кешу повного маршруту. Це типова причина, чому поведінка в dev не збігається з продакшеном.

Приклади

Базовий: мемоізація запитів через React.cache

tsx
// lib/data.ts import { cache } from 'react' import { db } from '@/lib/db' // Для ORM-запитів, які не йдуть через fetch export const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } }) }) // app/profile/[id]/page.tsx import { getUser } from '@/lib/data' async function Avatar({ id }: { id: string }) { const user = await getUser(id) // DB-запит відбувається один раз return <img src={user.avatar} alt={user.name} /> } export default async function Page({ params }: { params: { id: string } }) { const user = await getUser(params.id) // той самий виклик, результат вже є return ( <div> <h1>{user.name}</h1> <Avatar id={params.id} /> </div> ) } // Результат: один запит до БД для обох компонентів у тому ж рендері

React.cache() огортає функцію так, щоб повторні виклики з тими ж аргументами повертали закешований результат. Скидається на кожен запит, тому між користувачами дані не змішуються.

Середній: ISR-сторінка товару з тегованою ревалідацією

tsx
// app/products/[slug]/page.tsx export default async function ProductPage({ params }: { params: { slug: string } }) { const res = await fetch(`https://api.example.com/products/${params.slug}`, { next: { revalidate: 3600, // мінімум щогодинна ревалідація tags: [`product-${params.slug}`] // або на вимогу через revalidateTag } }) const product = await res.json() return ( <div> <h1>{product.name}</h1> <p>Залишок: {product.stock}</p> <p>{product.price} грн</p> </div> ) } // app/actions/updateStock.ts 'use server' import { revalidateTag } from 'next/cache' export async function updateStock(slug: string, newStock: number) { await fetch(`https://api.example.com/products/${slug}`, { method: 'PATCH', body: JSON.stringify({ stock: newStock }) }) revalidateTag(`product-${slug}`) // оновлюється лише сторінка цього товару }

Сторінка віддає закешований HTML поки не мине година або не спрацює updateStock. Все інше залишається в кеші. Це стандартний ISR-патерн для більшості ecommerce-проектів.

Просунутий: динамічний маршрут з частковим кешуванням

tsx
// app/dashboard/page.tsx // Маршрут динамічний (читає cookies для авторизації), // але публічний fetch все одно кешується import { cookies } from 'next/headers' export default async function Dashboard() { // Читання cookies робить маршрут динамічним const session = cookies().get('session-token') // Цей fetch все одно використовує кеш даних (5 хвилин) const statsRes = await fetch('https://api.example.com/stats', { next: { revalidate: 300 } }) // А цей оновлюється на кожен запит const userRes = await fetch(`/api/user/${session?.value}`, { cache: 'no-store' }) const user = await userRes.json() const stats = await statsRes.json() return ( <div> <h1>Вітаємо, {user.name}</h1> <p>Всього користувачів: {stats.total}</p> {/* з кешу, актуальність 5 хв */} </div> ) } // Результат: сторінка рендериться на кожен запит, дані користувача свіжі, // але статистика береться з кешу і пропускає API-запит більшість часу

Читання cookies() робить маршрут динамічним. Але окремі fetch-виклики всередині нього все одно використовують кеш даних, якщо не передати no-store. Динамічний рендеринг і кешування даних - це два незалежні перемикачі. Комбінування обох дозволяє отримувати свіжі дані користувача поряд із закешованими спільними даними в одному рендері.

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

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

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

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