Кэширование в Next.js App Router, как увидеть, почему данные не обновились
- пятница, 1 мая 2026 г. в 00:00:05
С кэшированием в Next.js обычно случается одна и та же история. API уже отдаёт новые данные, страница открывается заново, а на экране всё ещё старая версия. После этого в код быстро добавляют cache: "no-store", данные начинают запрашиваться на каждый заход, и через пару минут появляется уже другой вопрос - зачем тогда вообще нужен встроенный механизм кэширования.
Проблема в том, что кэширование обычно звучит как одно явление, а на практике в App Router похожие ощущения могут давать разные уровни поведения. Навигация назад и вперёд может переиспользовать клиентский кэш маршрута, сам маршрут может рендериться по-разному, а серверный fetch в Next.js имеет собственные стратегии кэширования и перевалидации. В актуальной документации это уже разделено на новый режим с Cache Components и на прежнюю модель без них. В этой статье речь именно о привычной модели App Router без Cache Components, где поведение обычно задаётся через fetch, cache, next.revalidate и route segment config. (Next.js)
Полезнее всего разбирать такую тему не с теории, а с наблюдения. Не с вопроса как устроены все слои кэша в Next.js, а с вопроса почему на одном и том же маршруте иногда обновляется рендер страницы, а иногда обновляются данные, и это не всегда одно и то же.
Для примеров ниже используется проект Goods Finder и внешний API DummyJSON. Идея - сначала добавить на страницу штамп серверного рендера, потом отдельно показать момент получения данных, а уже после этого сравнить force-cache, no-store и revalidate.
В App Router маршруты по умолчанию рендерятся на сервере, а навигация остаётся client-side. Shared layouts сохраняются, а back/forward могут переиспользовать уже посещённые страницы из client cache. Из-за этого внешне похожие ситуации на самом деле оказываются разными, где-то страница даже не пересчитывалась, а где-то страница пересчиталась, но данные пришли из кэша. (Next.js)
Самый короткий способ добавить на страницу серверный штамп времени.
// src/app/_ui/RenderStamp.js export default function RenderStamp({ label = "renderedAt" }) { const renderedAt = new Date().toISOString(); return ( <p className="text-xs text-slate-500"> {label}: <code className="font-mono">{renderedAt}</code> </p> ); }
Этот компонент серверный. Значение вычисляется во время серверного рендера. Если страница реально пересчиталась, время изменится. Если был показан повторный результат, штамп останется прежним.
Дальше его удобно подключить на список и на детальную страницу товара.
// src/app/(app)/goods/page.js import RenderStamp from "@/app/_ui/RenderStamp"; // ... JSX страницы <RenderStamp label="/goods render" />
// src/app/(app)/goods/[id]/page.js import RenderStamp from "@/app/_ui/RenderStamp"; // ... JSX страницы <RenderStamp label="goods/[id] render" />
После этого можно пройти обычный сценарий - открыть /goods, зайти в карточку товара, нажать Back, снова открыть карточку, обновить страницу. На навигации назад штамп не меняется. На reload он меняется. Уже на этом шаге становится видно, что не каждый повторный показ страницы означает новый серверный проход. То, что pages могут переиспользоваться при back/forward navigation, отдельно зафиксировано и в текущем glossary Next.js. (Next.js)
Следующий шаг полезнее любого объяснения про Data Cache. Нужно показать не только момент рендера, но и момент, когда пришли сами данные. Для этого в слой данных можно добавить ещё один маркер. В проекте он берётся из HTTP-заголовка Date.
// src/app/_data/dummyjson.js const API_BASE = "https://dummyjson.com"; async function fetchJson(url, fetchOptions = {}) { const res = await fetch(url, fetchOptions); if (!res.ok) { const text = await res.text().catch(() => ""); const err = new Error(`DummyJSON error: ${res.status} ${res.statusText}. ${text}`); err.status = res.status; throw err; } const fetchedAt = res.headers.get("date") || new Date().toUTCString(); const data = await res.json(); data.__fetchedAt = fetchedAt; return data; }
Теперь можно вернуть этот маркер на страницу рядом с RenderStamp.
// src/app/(app)/goods/[id]/page.js <p className="text-xs text-slate-500"> data fetched: <code className="font-mono">{item.__fetchedAt}</code> </p> <RenderStamp label="goods/[id] render" />
В этот момент кэширование перестаёт быть абстракцией. Есть два независимых сигнала. Первый показывает, пересчитывалась ли страница. Второй показывает, пришёл ли новый ответ от внешнего API.
Здесь становится видно главное, страница может пересчитаться заново, а данные при этом остаться прежними.
Теперь можно включить на запросах force-cache.
// src/app/_data/dummyjson.js export async function getProducts({ q = "", limit = 12, skip = 0 } = {}) { const safeQ = String(q).trim(); const qs = new URLSearchParams({ limit: String(limit), skip: String(skip), }); const url = safeQ ? `${API_BASE}/products/search?${qs.toString()}&q=${encodeURIComponent(safeQ)}` : `${API_BASE}/products?${qs.toString()}`; return fetchJson(url, { cache: "force-cache" }); } export async function getProductById(id) { const safeId = encodeURIComponent(String(id)); const url = `${API_BASE}/products/${safeId}`; return fetchJson(url, { cache: "force-cache" }); }
В актуальной документации force-cache описан буквально так - Next.js ищет совпадение в серверном кэше, если запись свежая, она возвращается из кэша, а если записи нет или она устарела, Next.js идёт в источник и обновляет кэш. (Next.js)
После npm run build && npm start можно открыть, например, /goods/1 и сделать несколько обычных обновлений страницы. Поведение выглядит показательно: goods/[id] render меняется, а data fetched может оставаться прежним. Страница пересчиталась, но данные были переиспользованы из кэша.
Это и есть та ловушка, из-за которой возникает ощущение данные залипли. На самом деле рендер живой, но источник данных не был запрошен заново.
Дальше достаточно заменить режим запроса:
// src/app/_data/dummyjson.js return fetchJson(url, { cache: "no-store" });
Документация определяет no-store прямо: Next.js запрашивает ресурс у удалённого сервера на каждый запрос, даже если request-time APIs на маршруте не использовались.
После пересборки и нескольких обновлений страницы уже видно другое поведение. goods/[id] render меняется, и data fetched тоже меняется на каждом обновлении. Здесь рендер и данные идут вместе, потому что сетевой ответ каждый раз новый.
С практической точки зрения это удобно для баланса, статусов заказа, личных кабинетов, внутренних панелей и любых данных, где устаревшая версия уже считается ошибкой. Но такой режим не всегда нужен для публичного каталога. Он увеличивает число запросов и нагружает внешний API там, где обычно хватает более долгой стратегии.
Для каталога, витрины и списков товаров чаще нужен промежуточный режим. Не постоянный сетевой запрос и не бесконечное переиспользование кэша, а окно свежести. В Next.js это делается через next.revalidate.
// src/app/_data/dummyjson.js const REVALIDATE_SECONDS = 15; export async function getProductById(id) { const safeId = encodeURIComponent(String(id)); const url = `${API_BASE}/products/${safeId}`; return fetchJson(url, { next: { revalidate: REVALIDATE_SECONDS } }); }
В официальной документации next.revalidate задаёт lifetime ресурса в секундах. 0 отключает кэширование, положительное число задаёт максимальное окно жизни записи, а false семантически эквивалентен бесконечному хранению. Чтобы этот режим было легче наблюдать, полезно добавить ещё один маркер freshUntil.
// внутри fetchJson const revalidateSec = fetchOptions?.next?.revalidate; const fetchedAtMs = Date.parse(fetchedAt); const freshUntil = Number.isFinite(fetchedAtMs) && Number.isFinite(revalidateSec) ? new Date(fetchedAtMs + revalidateSec * 1000).toUTCString() : ""; const data = await res.json(); data.__fetchedAt = fetchedAt; data.__revalidateSec = Number.isFinite(revalidateSec) ? revalidateSec : null; data.__freshUntil = freshUntil;
И вывести это рядом с рендер-штампом.
<p className="text-xs text-slate-500"> data fetched: <span className="font-mono">{item.__fetchedAt}</span> {item.__revalidateSec ? ( <> <span className="text-slate-300"> • </span> revalidate: <span className="font-mono">{item.__revalidateSec}s</span> <span className="text-slate-300"> • </span> fresh until: <span className="font-mono">{item.__freshUntil}</span> </> ) : null} </p> <RenderStamp label="goods/[id] render" />
Пока запросы укладываются в окно свежести, render может обновляться, а data fetched остаётся прежним. После выхода за окно свежести кэш начинает обновляться, и data fetched меняется. В документации это и описано как time-based revalidation через fetch(..., { next: { revalidate: N } }). Там же отдельно отмечено, что route-level revalidate не переопределяет более частую revalidation у отдельных fetch внутри маршрута.
На практике у новичка здесь часто ломается наблюдение. Он добавляет revalidate, обновляет страницу и не понимает, какой именно слой сейчас сработал. Чтобы эксперимент был чище, в проекте есть полезный приём - отключить Full Route Cache для сегмента, но оставить Data Cache у fetch. Это можно сделать через request-time API headers().
// src/app/(app)/goods/[id]/page.js import { headers } from "next/headers"; export default async function GoodsDetailsPage({ params }) { const p = await params; const item = await getProductById(p.id); headers(); return ( <div> <h1>{item.title}</h1> <RenderStamp label="goods/[id] render" /> </div> ); }
В текущей документации headers() описан как Request-time API. Его использование переводит маршрут в dynamic rendering. В guide по прежней модели кэширования также зафиксировано, что dynamic = "force-dynamic" эквивалентен режиму, где все fetch внутри страницы ведут себя как cache: 'no-store' и revalidate: 0. В той же guide отмечено, что по умолчанию fetch до request-time APIs может кэшироваться, а после них нет. (Next.js)
Иногда проблема не в том, что режим кэширования выбран неправильно. Проблема в том, что разные уровни кэша смешались, и разработчик наблюдает всё сразу.
В development поведение действительно отличается. В актуальной документации у fetch прямо сказано, что режим по умолчанию auto no cache в dev запрашивает ресурс на каждый запрос, но во время next build статически подготавливаемый маршрут может получить этот ресурс один раз. Там же указано, что в local development у Server Components есть HMR cache, который по умолчанию распространяется даже на cache: 'no-store'. Из-за этого uncached requests могут не показывать свежие данные между HMR refreshes. Этот кэш очищается при навигации или полном reload. Дополнительно hard refresh и отключённый cache в DevTools часто посылают cache-control: no-cache, и тогда cache, revalidate и tags могут быть проигнорированы, а запрос пойдёт прямо в источник.
Именно поэтому кэширование в Next.js лучше проверять не только в next dev, а обязательно через npm run build && npm start. Для этой темы production-режим не просто финальная проверка, а часть самой диагностики.
Чтобы не переключать код вручную между force-cache, no-store и revalidate, в Goods Finder была вынесена отдельная лаборатория /cache-lab. Режим кэша передаётся через query-параметры, слой данных принимает профиль кэша вторым аргументом, а список и карточка товара показывают рядом render, data fetched, revalidate и fresh until.
// src/app/_data/dummyjson.js function toCacheOptions(profile) { const mode = profile?.mode || "force-cache"; const n = Number(profile?.n); if (mode === "no-store") return { cache: "no-store" }; if (mode === "revalidate") { const revalidate = Number.isFinite(n) && n > 0 ? n : 15; return { next: { revalidate } }; } return { cache: "force-cache" }; }
export async function getProductById(id, cacheProfile) { const safeId = encodeURIComponent(String(id)); const url = `${API_BASE}/products/${safeId}`; return fetchJson(url, toCacheOptions(cacheProfile)); }
Такой стенд полезен по двум причинам. Во-первых, он даёт наглядную витрину для самой темы кэширования. Во-вторых, не засоряет боевой интерфейс /goods и /goods/[id], где учебные маркеры в итоговой версии уже не нужны.
Если данные должны быть свежими на каждый запрос, подходит no-store. Это история про баланс, статусы, динамические внутренние панели и всё, где устаревший ответ уже мешает.
Если данные меняются редко и источник нужно щадить, подходит force-cache. Это справочники, коллекции, публичные списки и всё, где небольшая задержка обновления допустима.
Если нужен рабочий компромисс для каталога, витрины или списка товаров, подходит next: { revalidate: N }. Именно этот вариант чаще всего оказывается базовым решением для публичных страниц. В guide по прежней модели кэширования route-level revalidate = false описан как поведение, близкое к бесконечному хранению для кэшируемых fetch, а положительное число задаёт частоту перевалидации маршрута, отдельные fetch могут уменьшать этот интервал. (Next.js)
Для Goods Finder практическим выбором стал режим revalidate: 60. Для каталога товаров это нормальный баланс между свежестью данных и числом запросов к внешнему API.
Главная польза кэширования в Next.js начинается в момент, когда становится видно, что рендер страницы и свежесть данных это разные вещи. Один RenderStamp показывает, пересчиталась ли страница. Один data fetched показывает, пришёл ли новый ответ. После этого force-cache, no-store и revalidate перестают быть абстрактными флагами и превращаются в обычные инженерные решения с понятным поведением.
Статья опирается на проект Goods Finder. Пройти эти паттерны последовательно можно на курсе Next.js I: JavaScript 2026