Data fetching и кэширование: пять уровней кэша
Кэширование в Next.js App Router — самая мощная и самая опасная часть фреймворка. Мощная, потому что из коробки тебе дают пять согласованных уровней кэша, которые в правильных руках превращают приложение в машину с TTFB около нуля. Опасная, потому что «пользователь видит старые данные» и «почему мы бьём в базу на каждый запрос» — два самых частых продакшен-бага в Next.js, и лечатся они пониманием, какой именно из пяти кэшей удержал (или не удержал) данные.
В краткой версии ты видел опции fetch и пару функций ревалидации. Здесь соберём полную картину по модели Next.js 15: как ведёт себя fetch по умолчанию (спойлер — он больше ничего не кэширует сам), все способы явно включить кэш, кэширование не-fetch данных через unstable_cache и дедупликацию запросов в рамках одного рендера.
Представь инцидент из другого лагеря: после обновления с Next.js 14 на 15 каталог вдруг начал ходить в API на каждый запрос, latency выросла вчетверо, а дашборд с расходами на внешний API покраснел. Причина одна — инвертированный дефолт. Без карты кэшей ты будешь гадать; с картой — откроешь код, увидишь «голые» fetch и поймёшь за минуту.
fetch в RSC: поведение по умолчанию в Next.js 15
Заголовок раздела «fetch в RSC: поведение по умолчанию в Next.js 15»В серверных компонентах fetch — не браузерный fetch. Это обёртка Next.js поверх нативного с дополнительными опциями и кэшированием. Ключевой факт, который нужно запомнить навсегда:
Три опции управления:
// 1. Дефолт в Next 15: без опций — кэша нет, запрос на каждый рендер.// Явный cache: 'no-store' — то же самое, но записано намеренно// (и запрещает статический пререндер маршрута).const posts = await fetch('https://api.example.com/posts', { cache: 'no-store',});
// 2. Включаем кэш бессрочно — то, что в Next 14 было дефолтомconst posts = await fetch('https://api.example.com/posts', { cache: 'force-cache',});
// 3. ISR на уровне данных: кэш на 60 секунд, потом фоновая регенерацияconst posts = await fetch('https://api.example.com/posts', { next: { revalidate: 60 },});
// 4. Теговая ревалидация: сброс по событию, а не по таймеруconst posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'], revalidate: 3600 }, // тег + страховочный интервал});Заметь: опции next: { revalidate } и next: { tags } сами включают кэш — force-cache к ним добавлять не нужно, кэширование подразумевается. Взаимоисключения: no-store несовместим с revalidate/tags — это два разных мира: «никогда не кэшировать» и «кэшировать с контролем свежести». Передашь конфликтующие опции вместе — приоритет получит no-store: revalidate/tags будут проигнорированы, а в консоли дев-сервера появится предупреждение.
Под капотом дефолт auto no cache работает так: на сборке Next.js пытается пререндерить маршрут статически, и все fetch до первого request-time API (cookies(), headers()) выполняются один раз и «запекаются» в результат. В рантайме на динамическом маршруте — полный запрос каждый раз. Кэшируются в Data Cache только ответы со статусом 200, а ключом служит URL + метод + заголовки + тело.
revalidatePath и revalidateTag
Заголовок раздела «revalidatePath и revalidateTag»Ручная ревалидация из серверного кода — Server Actions, route handlers (см. API-референс revalidateTag):
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishPost(id: string) { await db.post.update({ where: { id }, data: { published: true } });
revalidateTag('posts'); // сбросить все fetch с тегом 'posts' revalidatePath('/blog'); // сбросить Full Route Cache пути /blog revalidatePath(`/blog/${id}`); // и конкретной страницы поста}Разница принципиальная:
revalidatePath— точечный: сбрасывает кэш конкретного URL (и вложенных приlayout-типе). Работает на уровне маршрутов и данных, привязанных к ним.revalidateTag— концептуальный: сбрасывает все кэшированные ответы, помеченные тегом, где бы они ни использовались. Еслиpostsгрузятся и на главной, и в каталоге, и в виджете — один вызов обновляет везде.
Паттерн из практики: теги для сущностей (posts, products, user:123), revalidatePath — для URL, чьё содержимое не выводится из одной сущности (например, сложная страница с десятью источниками).
Кэширование не-fetch данных: unstable_cache
Заголовок раздела «Кэширование не-fetch данных: unstable_cache»fetch кэшируется опциями, а если данные грузятся напрямую из БД через Prisma — их нужно кэшировать вручную через unstable_cache:
import { unstable_cache } from 'next/cache';import { db } from './db';
export const getFeaturedProducts = unstable_cache( async () => { // выполнится один раз, потом — из кэша return db.product.findMany({ where: { featured: true }, include: { category: true }, }); }, ['featured-products'], // ключ кэша { tags: ['products'], revalidate: 600 }, // тег + интервал);Под капотом unstable_cache кладёт результат в тот же Data Cache, что и fetch — значит, работают те же revalidateTag, та же дедупликация, тот же контроль. Почему unstable_? API ещё может поменяться между минорными версиями, но на практике используется повсеместно — в Next 15 это единственный стабильный способ закэшировать не-fetch данные персистентно.
Плохой паттерн, который unstable_cache лечит:
// ❌ запрос в БД на каждый рендер страницы — дорого и медленноexport async function getCategories() { return db.category.findMany();}Пять уровней кэша: подробная таблица
Заголовок раздела «Пять уровней кэша: подробная таблица»| # | Кэш | Где живёт | Что хранит | Как сбрасывается | Время жизни |
|---|---|---|---|---|---|
| 1 | Request Memoization | Сервер, в рамках одного запроса | Результаты fetch/функций между компонентами |
Автоматически в конце запроса | Одна серверная обработка запроса |
| 2 | Data Cache | Сервер (персистентный) | Ответы fetch с force-cache/revalidate/tags, unstable_cache |
revalidateTag, revalidatePath, истечение revalidate |
От секунд до бессрочно |
| 3 | Full Route Cache | Сервер/CDN | Готовый HTML и RSC payload статических/ISR маршрутов | revalidatePath, revalidateTag (опосредованно), redeploy |
До ревалидации |
| 4 | Router Cache | Браузер | RSC payload для soft-навигации | router.refresh(), конец сессии |
5 мин (статика) / 0 сек (динамика, с Next 15) |
| 5 | Client Cache (fetch/browser) | Браузер | Стандартное поведение HTTP-кэша браузера | HTTP-заголовки (Cache-Control) |
По заголовкам |
Пройдём по сценариям:
Request Memoization (уровень 1). Ты вызвал fetch('/api/user') в layout, в page и в sidebar внутри одного запроса — реально выполнится один раз, два остальных возьмут результат из мемоизации. Работает только в рамках одного серверного рендера: для SSR-запроса или одной статической сборки. Важно: мемоизация действует и на некэшированные fetch — она не зависит от Data Cache. Это не кэш в привычном смысле, а временное дедуплирование.
Data Cache (уровень 2) — главный. Персистентный между запросами. В Next 15 попадают сюда только явно закэшированные вещи: fetch с force-cache/revalidate/tags и обёртки unstable_cache. Два таких fetch с одинаковым URL и опциями на разных страницах — один сетевой вызов на всё приложение, пока живёт кэш.
Full Route Cache (уровень 3) зависит от уровня 2: если данные внутри страницы свежие, HTML из Full Route Cache валиден. Ревалидация данных обычно триггерит пересборку маршрута, потому что Next.js инвалидирует связанные записи.
Router Cache (уровень 4) — уже известный тебе по главе про стратегии браузерный слой. С Next.js 15 страницы динамических маршрутов здесь по умолчанию вообще не задерживаются (staleTime: 0) — каждая soft-навигация идёт на сервер заново; кэшируются только статические и полностью префетченные страницы.
Дедупликация запросов
Заголовок раздела «Дедупликация запросов»Три механизма дедупликации работают одновременно и на разных уровнях:
- В рамках рендера (Request Memoization): повторные одинаковые вызовы схлопываются автоматически — в том числе для
fetchбез опций и дляunstable_cacheс одним ключом. Мемоизация не работает в Route Handlers: они не часть дерева компонентов. - Между запросами (Data Cache): только явно закэшированный
fetchпереиспользует ответ. Ключ — URL + метод + заголовки + опцииnext. - На клиенте: стандартные средства — SWR/React Query, браузерный HTTP-кэш. Next.js тут не помогает — сам думай про
staleTimeи инвалидацию.
Практическое следствие: не бойся вызывать getCurrentUser() в пяти компонентах — схлопнется в рамках рендера. Но помни: без кэша эта одна дедуплицированная копия выполнится заново на каждый запрос страницы. Если getCurrentUser() ходит в медленный сервис — оберни в unstable_cache с ключом по userId.
Схема выбора опций: шпаргалка для проектирования
Заголовок раздела «Схема выбора опций: шпаргалка для проектирования»Все решения по кэшированию сводятся к одному вопросу: какая задержка между изменением данных и их появлением на сайте допустима по бизнесу?
| Допустимая задержка | Опции fetch / кэша | Типовой пример |
|---|---|---|
| Только после redeploy | cache: 'force-cache' |
Версии API, редко меняющиеся справочники |
| Минуты | next: { revalidate: 60–600 } |
Каталог, курсы валют, агрегаторы |
| Секунды + событийный сброс | next: { revalidate: N, tags: [...] } |
Новости с ручной публикацией |
| Мгновенно, по событию | tags: [...] + revalidateTag из экшена |
Цена после изменения, остатки склада |
| Мгновенно, всегда свежий рендер | дефолт (без опций) или явный no-store |
Личный кабинет, корзина, дашборды |
Последний ряд — дефолт Next 15, и это ловушка в обе стороны. Культ «всегда свежо» дорог: каждый запрос идёт во все зависимости синхронно. Если в проекте на 15-й версии почти нет явных опций кэша — это повод пересмотреть требования: чаще всего бизнесу хватает «минута-две», а не «ноль».
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Миграция с 14 на 15 без аудита fetch. Код рассчитывал на дефолтный кэш — после апгрейда каждая страница ходит в API на каждый запрос. Плохо: «обновили версию, стало медленнее». Хорошо: перед миграцией пройтись по всем
fetchи явно проставитьforce-cache/revalidateтам, где кэш был нужен. -
Ожидание кэша там, где его нет. В Next 15
fetch(url)без опций не попадает в Data Cache:revalidateTagпо нему не сработает, потому что сбрасывать нечего. Хочешь событийную ревалидацию — сначала включи кэш тегом. -
Ревалидация данных без ревалидации маршрута. Вызвал
revalidateTag('products'), а Full Route Cache всё ещё отдаёт старый HTML, собранный со старыми данными. В большинстве случаев Next.js сам инвалидирует связанные маршруты, но в сложных конфигурациях (ручные кэши, сложные unstable_cache) надо звать иrevalidatePath. -
Дедупликация там, где её нет. «Я вызываю fetch в layout и page — точно ли один запрос?» В рамках рендера — да, но между запросами только при включённом кэше. И учти: добавил в одном месте
next: { tags: [...] }, а в другом нет — ключи разные, дедупликации нет, два запроса к API. -
Теги-односложности. Один тег
dataна всё приложение — любая ревалидация сбрасывает половину кэшей и нагружает систему пересборкой. Гранулярность тегов = гранулярность сущностей:post:42,products,user:7:orders. -
Кэширование приватных данных без разделения ключей.
unstable_cacheс ключом['user']для данных конкретного пользователя — сосед получит чужое. В ключ обязательно входит идентификатор пользователя:['user', userId, 'profile'].
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Сколько уровней кэша в Next.js и назови их. Пять: Request Memoization, Data Cache, Full Route Cache, Router Cache, клиентский HTTP-кэш браузера. Первые три — серверные, четвёртый — в браузере, пятый — стандартный HTTP.
- Кэшируется ли fetch в RSC по умолчанию? В Next.js 15 — нет: дефолт
auto no cache, запрос выполняется на каждый рендер (или один раз на сборке для статически пререндеренных маршрутов). Кэш включается явно:force-cache,revalidateилиtags. В Next.js 14 дефолт был обратный — кэш навсегда. - Чем revalidatePath отличается от revalidateTag? Path — сброс по URL (точечно, включая связанные данные), Tag — сброс по концептуальной метке на всех закэшированных ответах с этим тегом, где бы они ни использовались.
- Как закэшировать результат прямого запроса в БД (Prisma)?
unstable_cache(fn, keys, { tags, revalidate })— результат попадает в тот же Data Cache с поддержкой теговой ревалидации. (В новых версиях появляется директива'use cache', но в 15-й это эксперимент.) - Пользователь видит старые данные после мутации. Как локализуешь проблему? Проверяю по цепочке: Router Cache (браузер,
router.refresh()), Full Route Cache (путь,revalidatePath), Data Cache (теги/время,revalidateTag), затем Request Memoization (не должно переживать запрос). И проверяю обратное: не «всегда свежо» ли там, где должен быть кэш. - Работает ли дедупликация fetch с разными опциями? В рамках одного рендера схлопываются вызовы с тем же URL и теми же опциями; разные опции (
next: { tags }vs без них) — разные записи мемоизации, запросов будет два. Между запросами аналогично: ключ Data Cache включает URL, метод, заголовки и опции. - Можно ли закэшировать персонализированные данные? Можно, но ключ обязан включать идентификатор пользователя (и нельзя отдавать такой кэш через общий Full Route Cache — маршрут должен быть динамическим).
Практика
Заголовок раздела «Практика»- В pet-проекте замени все «голые»
fetchна явные с опциями: каталог —revalidate: 300+ тегproducts, страница поста — тегpost:<slug>, справочник валют —cache: 'force-cache', личный кабинет — оставь дефолт, но прокомментируй почему. - Оберни топ-3 запроса Prisma в
unstable_cacheс гранулярными тегами. Из Server Action создания товара вызовиrevalidateTag('products')и убедись, что каталог обновляется без redeploy. - Смоделируй конфликт: два
fetchодного URL, в одном естьtags, в другом нет. Проверь в логах API, что запросов два. Унифицируй опции и проверь снова — должен остаться один. - Напиши страницу
/debug-cache: выводит timestamps из Data Cache (черезunstable_cache) иDate.now()рендера. Посмотри, как они расходятся при ISR и что происходит послеrevalidateTag.
Критерий результата: по любому fetch в проекте видно, каким кэшем он управляется и каким тегом сбрасывается; страница каталога обновляется событием, а не redeploy — и при этом не ходит в API на каждый запрос.