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

Data fetching и кэширование: пять уровней кэша

Кэширование в Next.js App Router — самая мощная и самая опасная часть фреймворка. Мощная, потому что из коробки тебе дают пять согласованных уровней кэша, которые в правильных руках превращают приложение в машину с TTFB около нуля. Опасная, потому что «пользователь видит старые данные» — самый частый продакшен-баг в Next.js, и лечится он пониманием, какой именно из пяти кэшей удержал данные.

В краткой версии ты видел опции fetch и пару функций ревалидации. Здесь соберём полную картину: как ведёт себя fetch по умолчанию, все способы управления свежестью, кэширование не-fetch данных через unstable_cache и дедупликацию запросов в рамках одного рендера.

Представь инцидент: редактор опубликовал акцию, прошло двадцать минут, на сайте старое. Клиент пишет в поддержку. Без карты кэшей ты будешь гадать; с картой — откроешь логи, найдёшь, какой слой отдал старое, и применишь точечное лечение за минуту.

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

// app/posts/page.tsx — ⚠️ так делать нельзя без осознанной причины:
const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
// кэш навсегда. Акция, добавленная через админку, не появится до redeploy.

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

// 1. Всегда свежие данные (SSR-подобно)
const posts = await fetch('https://api.example.com/posts', {
cache: 'no-store',
});
// 2. ISR: кэш на 60 секунд, потом фоновая регенерация
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
// 3. Теговая ревалидация: сброс по событию, а не по таймеру
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'], revalidate: 3600 }, // тег + страховочный интервал
});

cache: 'force-cache' — явная запись дефолтного поведения (редко нужна, разве что для наглядности). Взаимоисключения: no-store несовместим с revalidate/tags — это два разных мира: «никогда не кэшировать» и «кэшировать с контролем свежести».

Ручная ревалидация из серверного кода — 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 ещё может поменяться между минорными версиями, но на практике используется повсеместно — альтернативы нет.

Плохой паттерн, который 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 (опосредованно), редeploy До ревалидации
4 Router Cache Браузер RSC payload для soft-навигации router.refresh(), конец сессии 5 мин (статика) / 30 сек (динамика)
5 Client Cache (fetch/browser) Браузер Стандартное поведение HTTP-кэша браузера HTTP-заголовки (Cache-Control) По заголовкам

Пройдём по сценариям:

Request Memoization (уровень 1). Ты вызвал fetch('/api/user') в layout, в page и в sidebar внутри одного запроса — реально выполнится один раз, два остальных возьмут результат из мемоизации. Работает только в рамках одного серверного рендера: для SSR-запроса или одной статической сборки. Это не кэш в привычном смысле — временное дедуплирование.

Data Cache (уровень 2) — главный. Персистентный между запросами. Два fetch с одинаковым URL и опциями на разных страницах — один сетевой вызов на всё приложение, пока живёт кэш.

Full Route Cache (уровень 3) зависит от уровня 2: если данные внутри страницы свежие, HTML из Full Route Cache валиден. Ревалидация данных обычно триггерит пересборку маршрута, потому что Next.js инвалидирует связанные записи.

Router Cache (уровень 4) — уже известный тебе по главе про стратегии браузерный слой: здесь «старые данные» — это норма дизайна, а не баг.

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

  1. В рамках рендера (Request Memoization): повторные одинаковые вызовы схлопываются автоматически. Даже unstable_cache с одним ключом в пределах одного рендера — один вызов.
  2. Между запросами (Data Cache): fetch с одинаковыми URL+опциями переиспользует закэшированный ответ. Ключ — URL + порядок опций next.
  3. На клиенте: стандартные средства — SWR/React Query, браузерный HTTP-кэш. Next.js тут не помогает — сам думай про staleTime и инвалидацию.

Практическое следствие: не бойся вызывать getCurrentUser() в пяти компонентах — схлопнется. Бойся вызывать fetch с no-store в пяти компонентах — это пять реальных запросов на каждый рендер страницы.

Схема выбора опций: шпаргалка для проектирования

Заголовок раздела «Схема выбора опций: шпаргалка для проектирования»

Все решения по кэшированию сводятся к одному вопросу: какая задержка между изменением данных и их появлением на сайте допустима по бизнесу?

Допустимая задержка Опции fetch / кэша Типовой пример
Только после redeploy дефолт (force-cache) Версии API, редко меняющиеся справочники
Минуты next: { revalidate: 60–600 } Каталог, курсы валют, агрегаторы
Секунды + событийный сброс next: { revalidate: N, tags: [...] } Новости с ручной публикацией
Мгновенно, по событию tags: [...] + revalidateTag из экшена Цена после изменения, остатки склада
Мгновенно, всегда свежий рендер cache: 'no-store' Личный кабинет, корзина, дашборды

Последний ряд — самый дорогой: каждый запрос идёт во все зависимости синхронно. Если видишь no-store на десяти маршрутах из двадцати — это повод пересмотреть требования, а не норма.

  1. Забытые опции fetch. Дефолтный fetch(url) в серверном компоненте кэшируется навсегда. Плохо: fetch(url) без опций и удивление «данные не обновляются». Хорошо: явные no-store или revalidate/tags по требованию бизнеса.

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

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

  4. no-store как культ. Ставить no-store на всё «чтобы не мучиться» — это добровольный отказ от 90% ценности фреймворка: каждая страница ходит во все сервисы синхронно, TTFB растёт, база страдает. Сначала реши, какая задержка допустима — чаще всего ответ «минута-две», а не «ноль».

  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? Ответ кэшируется в Data Cache бессрочно (force-cache). Управляется опциями cache и next: { revalidate, tags }.
  3. Чем revalidatePath отличается от revalidateTag? Path — сброс по URL (точечно, включая связанные данные), Tag — сброс по концептуальной метке на всех закэшированных ответах с этим тегом, где бы они ни использовались.
  4. Как закэшировать результат прямого запроса в БД (Prisma)? unstable_cache(fn, keys, { tags, revalidate }) — результат попадает в тот же Data Cache с поддержкой теговой ревалидации.
  5. Пользователь видит старые данные после мутации. Как локализуешь проблему? Проверяю по цепочке: Router Cache (браузер, router.refresh()), Full Route Cache (путь, revalidatePath), Data Cache (теги/время, revalidateTag), затем Request Memoization (не должно переживать запрос).
  6. Работает ли дедупликация fetch с разными опциями? Нет: ключ дедупликации включает URL и опции. Два вызова с разными tags/revalidate — два независимых кэша.
  7. Можно ли закэшировать персонализированные данные? Можно, но ключ обязан включать идентификатор пользователя (и нельзя отдавать такой кэш через общий Full Route Cache — маршрут должен быть динамическим).
  1. В pet-проекте замени все «голые» fetch на явные с опциями: каталог — revalidate: 300 + тег products, страница поста — тег post:<slug>, личный кабинет — no-store.
  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.