Перейти к содержимому

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 — не браузерный fetch. Это обёртка Next.js поверх нативного с дополнительными опциями и кэшированием. Ключевой факт, который нужно запомнить навсегда:

Три опции управления:

app/posts/page.tsx
// 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 + метод + заголовки + тело.

Ручная ревалидация из серверного кода — Server Actions, route handlers (см. API-референс revalidateTag):

app/actions.ts
'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 кэшируется опциями, а если данные грузятся напрямую из БД через Prisma — их нужно кэшировать вручную через unstable_cache:

lib/products.ts
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-навигация идёт на сервер заново; кэшируются только статические и полностью префетченные страницы.

Три механизма дедупликации работают одновременно и на разных уровнях:

  1. В рамках рендера (Request Memoization): повторные одинаковые вызовы схлопываются автоматически — в том числе для fetch без опций и для unstable_cache с одним ключом. Мемоизация не работает в Route Handlers: они не часть дерева компонентов.
  2. Между запросами (Data Cache): только явно закэшированный fetch переиспользует ответ. Ключ — URL + метод + заголовки + опции next.
  3. На клиенте: стандартные средства — 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-й версии почти нет явных опций кэша — это повод пересмотреть требования: чаще всего бизнесу хватает «минута-две», а не «ноль».

  1. Миграция с 14 на 15 без аудита fetch. Код рассчитывал на дефолтный кэш — после апгрейда каждая страница ходит в API на каждый запрос. Плохо: «обновили версию, стало медленнее». Хорошо: перед миграцией пройтись по всем fetch и явно проставить force-cache/revalidate там, где кэш был нужен.

  2. Ожидание кэша там, где его нет. В Next 15 fetch(url) без опций не попадает в Data Cache: revalidateTag по нему не сработает, потому что сбрасывать нечего. Хочешь событийную ревалидацию — сначала включи кэш тегом.

  3. Ревалидация данных без ревалидации маршрута. Вызвал revalidateTag('products'), а Full Route Cache всё ещё отдаёт старый HTML, собранный со старыми данными. В большинстве случаев Next.js сам инвалидирует связанные маршруты, но в сложных конфигурациях (ручные кэши, сложные unstable_cache) надо звать и revalidatePath.

  4. Дедупликация там, где её нет. «Я вызываю fetch в layout и page — точно ли один запрос?» В рамках рендера — да, но между запросами только при включённом кэше. И учти: добавил в одном месте next: { tags: [...] }, а в другом нет — ключи разные, дедупликации нет, два запроса к API.

  5. Теги-односложности. Один тег data на всё приложение — любая ревалидация сбрасывает половину кэшей и нагружает систему пересборкой. Гранулярность тегов = гранулярность сущностей: post:42, products, user:7:orders.

  6. Кэширование приватных данных без разделения ключей. unstable_cache с ключом ['user'] для данных конкретного пользователя — сосед получит чужое. В ключ обязательно входит идентификатор пользователя: ['user', userId, 'profile'].

  1. Сколько уровней кэша в Next.js и назови их. Пять: Request Memoization, Data Cache, Full Route Cache, Router Cache, клиентский HTTP-кэш браузера. Первые три — серверные, четвёртый — в браузере, пятый — стандартный HTTP.
  2. Кэшируется ли fetch в RSC по умолчанию? В Next.js 15 — нет: дефолт auto no cache, запрос выполняется на каждый рендер (или один раз на сборке для статически пререндеренных маршрутов). Кэш включается явно: force-cache, revalidate или tags. В Next.js 14 дефолт был обратный — кэш навсегда.
  3. Чем revalidatePath отличается от revalidateTag? Path — сброс по URL (точечно, включая связанные данные), Tag — сброс по концептуальной метке на всех закэшированных ответах с этим тегом, где бы они ни использовались.
  4. Как закэшировать результат прямого запроса в БД (Prisma)? unstable_cache(fn, keys, { tags, revalidate }) — результат попадает в тот же Data Cache с поддержкой теговой ревалидации. (В новых версиях появляется директива 'use cache', но в 15-й это эксперимент.)
  5. Пользователь видит старые данные после мутации. Как локализуешь проблему? Проверяю по цепочке: Router Cache (браузер, router.refresh()), Full Route Cache (путь, revalidatePath), Data Cache (теги/время, revalidateTag), затем Request Memoization (не должно переживать запрос). И проверяю обратное: не «всегда свежо» ли там, где должен быть кэш.
  6. Работает ли дедупликация fetch с разными опциями? В рамках одного рендера схлопываются вызовы с тем же URL и теми же опциями; разные опции (next: { tags } vs без них) — разные записи мемоизации, запросов будет два. Между запросами аналогично: ключ Data Cache включает URL, метод, заголовки и опции.
  7. Можно ли закэшировать персонализированные данные? Можно, но ключ обязан включать идентификатор пользователя (и нельзя отдавать такой кэш через общий Full Route Cache — маршрут должен быть динамическим).
  1. В pet-проекте замени все «голые» fetch на явные с опциями: каталог — revalidate: 300 + тег products, страница поста — тег post:<slug>, справочник валют — cache: 'force-cache', личный кабинет — оставь дефолт, но прокомментируй почему.
  2. Оберни топ-3 запроса Prisma в unstable_cache с гранулярными тегами. Из Server Action создания товара вызови revalidateTag('products') и убедись, что каталог обновляется без redeploy.
  3. Смоделируй конфликт: два fetch одного URL, в одном есть tags, в другом нет. Проверь в логах API, что запросов два. Унифицируй опции и проверь снова — должен остаться один.
  4. Напиши страницу /debug-cache: выводит timestamps из Data Cache (через unstable_cache) и Date.now() рендера. Посмотри, как они расходятся при ISR и что происходит после revalidateTag.

Критерий результата: по любому fetch в проекте видно, каким кэшем он управляется и каким тегом сбрасывается; страница каталога обновляется событием, а не redeploy — и при этом не ходит в API на каждый запрос.