Wprowadzenie: Dlaczego cachowanie w Next.js to zupełnie nowy temat w 2026
Ręka do góry — kto pracował z App Routerem w 2024 roku i miał ochotę wyrwać sobie włosy z głowy? Żądania fetch cachowane domyślnie, całe trasy lądujące w Full Route Cache bez żadnego ostrzeżenia, kliencki Router Cache serwujący przestarzałe dane przez minuty... Brzmi znajomo? Nie byłeś sam. Społeczność Next.js była głośna w tej kwestii — i trzeba przyznać, Vercel naprawdę słuchał.
W Next.js 15 i 16 model cachowania został fundamentalnie przebudowany. I szczerze? To chyba najlepsza zmiana, jaka spotkała ten framework od dłuższego czasu. Zamiast niejawnego cachowania „wszystkiego i wszędzie", mamy teraz podejście opt-in — nic nie jest cachowane, dopóki świadomie o to nie poprosisz. Centrum tej rewolucji to nowa dyrektywa 'use cache' i towarzyszące jej API: cacheTag, cacheLife, revalidateTag i zupełnie nowy updateTag.
W tym artykule przejdziemy przez cały nowy model cachowania w Next.js App Router — od warstw cache, przez konfigurację Cache Components, po strategie rewalidacji (ISR, on-demand tagami, natychmiastowe odświeżanie z updateTag). Każdy wzorzec zilustrowałem praktycznym kodem TypeScript, więc możesz od razu przenieść to do swojego projektu.
A jeśli czytałeś nasze poprzednie artykuły o Middleware i Edge Runtime oraz Server Actions — traktuj ten materiał jako trzeci element układanki. Middleware łapie żądania przed renderowaniem, Server Actions ogarniają mutacje danych, a cachowanie decyduje o tym, jak długo wyniki żyją i kiedy je odświeżać. Razem tworzą kompletny obraz przetwarzania danych w App Routerze.
Warstwy cache w Next.js: Pełny obraz
No dobra, zanim rzucimy się na kod, musimy ogarnąć architekturę cachowania. Bo Next.js nie ma jednego cache — ma ich kilka, działających na zupełnie różnych poziomach. I powiem tak: zrozumienie tych warstw to absolutny klucz do unikania tych momentów, gdy siedzisz o 23:00 i zastanawiasz się, dlaczego dane się nie odświeżają.
Request Memoization — deduplikacja w ramach renderowania
Pierwsza warstwa jest w sumie najprostsza i działa wyłącznie podczas pojedynczego renderowania po stronie serwera. Jeśli kilka komponentów w jednym drzewie renderowania wywołuje fetch z tym samym URL-em i opcjami, Next.js automatycznie deduplikuje te żądania — fizycznie wykona się tylko jedno. To ma fajną implikację praktyczną: możesz spokojnie pobierać dane dokładnie tam, gdzie ich potrzebujesz (kolokacja danych z komponentami), bez obawy, że API dostanie salwę identycznych requestów.
// app/dashboard/layout.tsx — pobiera dane użytkownika
const user = await fetch('/api/user/me')
// app/dashboard/page.tsx — pobiera te same dane, ale fetch jest deduplikowany
const user = await fetch('/api/user/me')
// Faktycznie wykona się tylko JEDNO żądanie HTTP
Request Memoization działa automatycznie — zero konfiguracji. Czas życia? Pojedynczy cykl renderowania serwera i koniec.
Data Cache — trwałe przechowywanie wyników fetch
Data Cache to warstwa, która przechowuje wyniki żądań fetch między różnymi żądaniami HTTP, a nawet między wdrożeniami. I tu ważna sprawa: w Next.js 15+ żądania fetch nie są cachowane domyślnie. Musisz jawnie włączyć cachowanie za pomocą cache: 'force-cache' lub dyrektywy 'use cache'. Koniec z niespodziankami.
// Nie cachowane (domyślne zachowanie w Next.js 15+)
const data = await fetch('https://api.example.com/posts')
// Cachowane na stałe
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache'
})
// Cachowane z automatyczną rewalidacją co godzinę (ISR)
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }
})
Full Route Cache — cache całych wyrenderowanych tras
Full Route Cache przechowuje kompletny wynik renderowania trasy — HTML + RSC Payload — na serwerze. W praktyce trasy statyczne (te, które nie korzystają z dynamicznych API typu cookies, headers czy searchParams) mogą być w pełni zcachowane podczas budowania lub po rewalidacji. Efekt? Kolejne żądania do tej samej trasy nie wymagają ponownego renderowania. Szybkość jest odczuwalna gołym okiem.
Router Cache — cache po stronie klienta
Router Cache to warstwa żyjąca w przeglądarce — przechowuje RSC Payload odwiedzonych tras i zapewnia tę przyjemną, natychmiastową nawigację między stronami bez dodatkowych żądań do serwera. Jest tymczasowy: dane siedzą w pamięci przeglądarki i znikają po odświeżeniu strony lub zamknięciu karty.
Ale uwaga na pewien „gotcha": klient wymusza minimalne 30 sekund czasu stale, niezależnie od tego, co sobie ustawisz. Nawet jeśli dasz stale: 0, Router Cache przez 30 sekund i tak będzie serwował zcachowaną wersję. Warto o tym wiedzieć, zanim zaczniesz szukać buga, który nie istnieje.
Cache Components i dyrektywa 'use cache': Nowy standard
I tu robi się naprawdę ciekawie. Cache Components to moim zdaniem najważniejsza zmiana w modelu cachowania Next.js od czasu pojawienia się App Routera. Zamiast zgadywać, co framework cachuje za naszymi plecami, mamy teraz jawną, deklaratywną kontrolę — co cachować, jak długo, pod jakimi tagami. A w centrum tego wszystkiego stoi dyrektywa 'use cache'.
Włączanie Cache Components
Żeby w ogóle zacząć, musisz włączyć flagę cacheComponents w next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Trzy poziomy zastosowania 'use cache'
Dyrektywę 'use cache' możesz wrzucić na trzech różnych poziomach — i naprawdę warto znać różnice między nimi, bo od tego zależy granularność cachowania.
Poziom pliku — cachowanie wszystkich eksportów
// app/products/page.tsx
'use cache'
import { getProducts } from '@/lib/data'
export default async function ProductsPage() {
const products = await getProducts()
return (
<main>
<h1>Nasze produkty</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} — {p.price} zł</li>
))}
</ul>
</main>
)
}
Gdy 'use cache' ląduje na górze pliku, wszystkie eksportowane funkcje i komponenty z tego pliku będą cachowane. Wygodne dla stron, które w całości mogą być statyczne — ale nie przesadzajcie z tym podejściem, bo łatwo cachować za dużo.
Poziom komponentu — cachowanie wybranego komponentu
// components/featured-products.tsx
import { cacheLife, cacheTag } from 'next/cache'
export async function FeaturedProducts() {
'use cache'
cacheLife('hours')
cacheTag('featured-products')
const products = await db.product.findMany({
where: { featured: true },
take: 6,
})
return (
<section>
<h2>Polecane produkty</h2>
{products.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</section>
)
}
Poziom funkcji — cachowanie logiki pobierania danych
// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getPostBySlug(slug: string) {
'use cache'
cacheLife('days')
cacheTag('posts', `post-${slug}`)
return db.post.findUnique({
where: { slug },
include: { author: true, comments: true },
})
}
To jest mój ulubiony i najczęściej rekomendowany wzorzec — cachowanie na poziomie funkcji pobierania danych. Argumenty funkcji (tu: slug) automatycznie stają się częścią klucza cache, więc różne slugi tworzą osobne wpisy. Eleganckie i przewidywalne.
Automatyczne klucze cache
Jedną z najwygodniejszych cech 'use cache' jest to, że kompilator Next.js automatycznie generuje klucze cache na podstawie argumentów funkcji i domknięć (closures). Zero ręcznego zarządzania kluczami. Wywołujesz getPostBySlug('moj-post') i getPostBySlug('inny-post')? Dwa osobne wpisy w cache. Framework ogarnia to za Ciebie i — szczerze mówiąc — robi to lepiej, niż większość z nas robiła ręcznie.
cacheLife: Precyzyjna kontrola czasu życia cache
Funkcja cacheLife pozwala dokładnie określić, jak długo cachowane dane mają żyć. Działa wyłącznie wewnątrz funkcji lub komponentów oznaczonych dyrektywą 'use cache' — poza tym kontekstem nie ma sensu.
Wbudowane profile
Next.js daje kilka gotowych profili, żeby nie trzeba było za każdym razem przekopywać się przez dokumentację:
'default'— domyślny profil, odpowiedni dla większości przypadków'seconds'— bardzo krótki czas życia'minutes'— kilka minut'hours'— kilka godzin'days'— kilka dni'weeks'— tydzień lub dłużej'max'— najdłuższy możliwy czas
import { cacheLife } from 'next/cache'
export async function getStaticContent() {
'use cache'
cacheLife('weeks') // Treść zmienia się rzadko
return db.page.findMany({ where: { type: 'static' } })
}
export async function getLatestNews() {
'use cache'
cacheLife('minutes') // Wiadomości odświeżane co kilka minut
return db.news.findMany({ orderBy: { createdAt: 'desc' }, take: 10 })
}
Trzy parametry: stale, revalidate, expire
No dobra, przejdźmy do mięsa. Każdy profil cachowania opiera się na trzech parametrach czasowych i zrozumienie ich jest naprawdę kluczowe — bo tu tkwi serce całego mechanizmu:
- stale — jak długo klient może korzystać z cachowanych danych bez sprawdzania serwera. W tym oknie nawigacja jest błyskawiczna, ale dane mogą być nieaktualne.
- revalidate — po upływie tego czasu kolejne żądanie wywoła odświeżanie w tle (stale-while-revalidate). Użytkownik dostaje starą wersję natychmiast, a nowa generuje się asynchronicznie.
- expire — po upływie tego czasu (i braku żądań) wpis cache po prostu znika. Następne żądanie musi poczekać na wygenerowanie świeżych danych — bez żadnego fallbacku.
Ważna reguła, o której łatwo zapomnieć: wartość expire musi być większa niż revalidate. Next.js waliduje to i grzecznie rzuci błędem, jeśli coś pokręcisz.
Własne profile w next.config.ts
Zamiast rozrzucać magiczne liczby po całym projekcie (wiemy wszyscy, jak to się kończy), możesz zdefiniować własne, semantyczne profile. Moim zdaniem to jedna z tych rzeczy, które powinny być standardem w każdym projekcie Next.js:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
// Treści blogowe — odświeżane co 15 minut
blog: {
stale: 3600, // 1 godzina na kliencie
revalidate: 900, // 15 minut na serwerze
expire: 86400, // 1 dzień max
},
// Dane produktowe — odświeżane co 5 minut
produkty: {
stale: 300, // 5 minut na kliencie
revalidate: 300, // 5 minut na serwerze
expire: 3600, // 1 godzina max
},
// Strony statyczne — rzadko zmieniane
statyczne: {
stale: 86400, // 1 dzień na kliencie
revalidate: 43200, // 12 godzin na serwerze
expire: 604800, // 7 dni max
},
},
}
export default nextConfig
A potem w kodzie po prostu odwołujesz się do nazwy profilu:
import { cacheLife } from 'next/cache'
export async function getBlogPosts() {
'use cache'
cacheLife('blog') // Używa profilu zdefiniowanego w next.config.ts
return db.post.findMany({ orderBy: { createdAt: 'desc' } })
}
export async function getProduct(id: string) {
'use cache'
cacheLife('produkty')
return db.product.findUnique({ where: { id } })
}
Czytelne, utrzymywalne, a zmiana strategii cachowania to update w jednym pliku zamiast polowania po całym codebase.
Inline profile — jednorazowa konfiguracja
Czasem potrzebujesz niestandardowych wartości tylko w jednym konkretnym miejscu i nie chce Ci się tworzyć profilu. Fair enough — możesz przekazać obiekt bezpośrednio:
import { cacheLife } from 'next/cache'
export async function getFlashSaleProducts() {
'use cache'
cacheLife({
stale: 60, // 1 minuta na kliencie
revalidate: 30, // 30 sekund na serwerze
expire: 300, // 5 minut max
})
return db.product.findMany({
where: { flashSale: true, endsAt: { gt: new Date() } },
})
}
cacheTag: Tagowanie i inwalidacja on-demand
Funkcja cacheTag pozwala oznaczać wpisy cache tagami, które potem służą do precyzyjnej inwalidacji. Powiem wprost — to jedno z najpotężniejszych narzędzi w arsenale cachowania Next.js i jeśli czegokolwiek nauczysz się z tego artykułu, niech to będzie właśnie to.
Podstawowe tagowanie
import { cacheTag, cacheLife } from 'next/cache'
export async function getCategories() {
'use cache'
cacheLife('days')
cacheTag('categories')
return db.category.findMany({ orderBy: { sortOrder: 'asc' } })
}
export async function getPostsByCategory(categorySlug: string) {
'use cache'
cacheLife('hours')
cacheTag('posts', `category-${categorySlug}`)
return db.post.findMany({
where: { category: { slug: categorySlug } },
orderBy: { createdAt: 'desc' },
})
}
Do jednego wpisu cache możesz przypiąć wiele tagów — po prostu przekaż je jako kolejne argumenty. Limity? Jeden wpis wytrzyma do 128 tagów, a każdy tag może mieć do 256 znaków. W praktyce raczej nie trafisz na te ograniczenia.
Strategia tagowania dla typowego projektu
Dobrze przemyślana strategia tagowania oszczędza naprawdę mnóstwo problemów w przyszłości. Pokażę Ci wzorzec, który sprawdza się w moich projektach:
// lib/cache-tags.ts — centralny plik z tagami cache
export const CacheTags = {
// Tagi globalne
allPosts: 'posts',
allProducts: 'products',
allCategories: 'categories',
// Tagi per-zasób
post: (slug: string) => `post-${slug}`,
product: (id: string) => `product-${id}`,
category: (slug: string) => `category-${slug}`,
// Tagi per-użytkownik
userProfile: (userId: string) => `user-${userId}`,
userCart: (userId: string) => `cart-${userId}`,
} as const
// lib/data.ts — funkcje danych z tagami
import { cacheTag, cacheLife } from 'next/cache'
import { CacheTags } from './cache-tags'
export async function getPost(slug: string) {
'use cache'
cacheLife('days')
cacheTag(CacheTags.allPosts, CacheTags.post(slug))
return db.post.findUnique({ where: { slug } })
}
export async function getProduct(id: string) {
'use cache'
cacheLife('produkty')
cacheTag(CacheTags.allProducts, CacheTags.product(id))
return db.product.findUnique({ where: { id } })
}
Dzięki centralnemu plikowi unikasz literówek (a te potrafią kosztować godziny debugowania) i masz jedno miejsce do zarządzania strategią inwalidacji. Proste, a działa cuda.
Rewalidacja: Trzy podejścia do odświeżania cache
Masz cachowane dane — super. Ale co, gdy się zmienią? Next.js oferuje trzy mechanizmy rewalidacji i każdy ma swoje miejsce. Przejdźmy przez nie po kolei.
1. Rewalidacja czasowa (ISR)
Incremental Static Regeneration — stary, dobry ISR. Najstarszy i najprostszy mechanizm. Ustawiasz interwał, a Next.js automatycznie odświeża dane po jego upływie, stosując wzorzec stale-while-revalidate.
// Podejście 1: Przez fetch options
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Co godzinę
})
// Podejście 2: Przez route segment config
export const revalidate = 3600
// Podejście 3: Przez cacheLife (rekomendowane w Next.js 16)
import { cacheLife } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours') // Profil z wbudowaną rewalidacją
return db.post.findMany()
}
ISR sprawdza się świetnie dla treści zmieniających się w przewidywalnych odstępach — blogi, strony informacyjne, katalogi produktów. Ma jednak jedno oczywiste ograniczenie: nie wymusisz natychmiastowego odświeżenia, gdy treść się zmieni. Na to mamy kolejne narzędzia.
2. revalidateTag — rewalidacja on-demand z SWR
Funkcja revalidateTag pozwala unieważnić cache na żądanie, celując po tagach. W aktualnej wersji Next.js rekomendowane jest użycie z profilem 'max', który stosuje semantykę stale-while-revalidate:
// app/actions/blog.ts
'use server'
import { revalidateTag } from 'next/cache'
import { CacheTags } from '@/lib/cache-tags'
export async function publishPost(slug: string) {
await db.post.update({
where: { slug },
data: { status: 'published', publishedAt: new Date() },
})
// Oznacz tag jako przestarzały — następne żądanie wywoła
// odświeżanie w tle (SWR)
revalidateTag(CacheTags.post(slug), 'max')
revalidateTag(CacheTags.allPosts, 'max')
}
Wywołanie revalidateTag z profilem 'max' nie odświeża danych od razu. Oznacza wpisy z danym tagiem jako przestarzałe, a faktyczne odświeżenie nastąpi dopiero przy następnym żądaniu. Użytkownik dostaje starą wersję, nowa generuje się w tle. Dla większości scenariuszy to wystarczy.
Ważna zmiana, na którą warto zwrócić uwagę: jednoargumentowa forma revalidateTag(tag) (bez profilu) jest przestarzała (deprecated). Zawsze podawaj drugi argument.
3. updateTag — natychmiastowe odświeżanie (read-your-own-writes)
A teraz gwóźdź programu. Funkcja updateTag to nowość w Next.js, zaprojektowana specjalnie na te momenty, kiedy użytkownik musi natychmiast zobaczyć efekt swojej zmiany. Edytuje profil? Zmienia ustawienia? Aktualizuje koszyk? To jest dokładnie na to. W przeciwieństwie do revalidateTag, updateTag natychmiast wygasza cache, a następne żądanie czeka na świeże dane zamiast podawać stare.
// app/actions/profile.ts
'use server'
import { updateTag } from 'next/cache'
import { CacheTags } from '@/lib/cache-tags'
import { auth } from '@/lib/auth'
export async function updateProfile(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Brak autoryzacji')
const name = formData.get('name') as string
const bio = formData.get('bio') as string
await db.user.update({
where: { id: session.user.id },
data: { name, bio },
})
// updateTag — użytkownik zobaczy zmiany NATYCHMIAST
updateTag(CacheTags.userProfile(session.user.id))
}
Kluczowe ograniczenie: updateTag działa wyłącznie z Server Actions. Nie w Route Handlers, nie w Client Components, nie nigdzie indziej. Jeśli potrzebujesz inwalidacji z Route Handlera (np. webhook), sięgnij po revalidateTag.
revalidatePath — inwalidacja po ścieżce
Oprócz tagów, jest jeszcze opcja inwalidacji po konkretnej ścieżce URL:
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
// Inwaliduj stronę z listą postów
revalidatePath('/blog')
}
Mała uwaga: revalidatePath('/blog') inwaliduje tylko tę konkretną ścieżkę, nie strony podrzędne jak /blog/moj-post. Jeśli dane żyją na wielu stronach, tagi są po prostu wygodniejsze. Osobiście sięgam po revalidatePath dość rzadko.
Kiedy użyć którego mechanizmu?
Żeby nie trzeba było wracać do poprzednich sekcji — szybkie podsumowanie:
- ISR (cacheLife/revalidate) — treści zmieniające się w przewidywalnych interwałach (blogi, katalogi). Najprostszy mechanizm, „ustaw i zapomnij".
- revalidateTag z 'max' — dane współdzielone między stronami, webhooki od CMS-a. Nie blokuje użytkownika, odświeża w tle.
- updateTag — użytkownik edytuje swoje dane i musi je natychmiast zobaczyć (profil, koszyk, ustawienia). Blokujące, ale tego właśnie chcesz.
- revalidatePath — inwalidacja konkretnej ścieżki, gdy nie używasz tagów. Prostsze, ale mniej elastyczne.
Warianty 'use cache': private i remote
Oprócz standardowej dyrektywy 'use cache', Next.js dorzuca dwa wyspecjalizowane warianty na bardziej zaawansowane scenariusze.
'use cache: private' — cache per-użytkownik
Standardowe 'use cache' tworzy współdzielony cache — każdy użytkownik widzi te same dane. Ale co z danymi specyficznymi dla użytkownika? Panel kontrolny, spersonalizowane rekomendacje, statystyki konta?
// components/user-dashboard.tsx
import { cacheLife, cacheTag } from 'next/cache'
export async function UserDashboard({ userId }: { userId: string }) {
'use cache: private'
cacheLife('minutes')
cacheTag(`dashboard-${userId}`)
const stats = await db.userStats.findUnique({
where: { userId },
})
return (
<div>
<h2>Twój panel</h2>
<p>Artykuły: {stats?.postsCount}</p>
<p>Komentarze: {stats?.commentsCount}</p>
</div>
)
}
'use cache: private' gwarantuje, że cachowane dane nie trafią przypadkiem do innego użytkownika. To ważne — zwłaszcza jeśli cachujecie cokolwiek zawierającego dane osobowe. Drobna uwaga: ta dyrektywa nie jest dostępna w Route Handlers.
'use cache: remote' — zdalny, współdzielony cache
W środowiskach serverless (a kto dziś nie jest serverless?) każde żądanie może trafić na inną instancję. Lokalny cache w pamięci jest wtedy mało użyteczny — instancja A coś zcachuje, a instancja B o tym nie wie. Dyrektywa 'use cache: remote' kieruje cachowanie do zewnętrznego magazynu (Redis, KV store), dzięki czemu wszystkie instancje współdzielą ten sam cache.
// lib/data.ts
export async function getGlobalConfig() {
'use cache: remote'
cacheLife('hours')
cacheTag('global-config')
return db.config.findFirst()
}
Żeby to działało, musisz skonfigurować cacheHandlers w next.config.ts, wskazując niestandardowy handler dla zewnętrznego magazynu. To trochę więcej pracy, ale w architekturze serverless to jedyny sensowny sposób na efektywny cache po stronie serwera.
Produkcyjne wzorce: Kompletne scenariusze
Teoria teorią, ale zobaczmy jak to wszystko współpracuje w realnym świecie. Poniżej dwa wzorce, które stosowałem w produkcyjnych projektach.
Wzorzec 1: Blog z CMS (headless)
// lib/blog.ts — warstwa danych bloga
import { cacheTag, cacheLife } from 'next/cache'
export async function getBlogPosts(page: number = 1) {
'use cache'
cacheLife('blog') // Nasz profil: stale 1h, revalidate 15min
cacheTag('posts', `posts-page-${page}`)
return db.post.findMany({
where: { status: 'published' },
orderBy: { publishedAt: 'desc' },
skip: (page - 1) * 10,
take: 10,
include: { author: true, category: true },
})
}
export async function getBlogPost(slug: string) {
'use cache'
cacheLife('blog')
cacheTag('posts', `post-${slug}`)
return db.post.findUnique({
where: { slug, status: 'published' },
include: { author: true, category: true },
})
}
// app/api/cms-webhook/route.ts — webhook od CMS
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
export async function POST(request: Request) {
const headersList = await headers()
const secret = headersList.get('x-webhook-secret')
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return Response.json({ error: 'Brak autoryzacji' }, { status: 401 })
}
const body = await request.json()
const { slug, action } = body
// Inwaliduj cache z expire: 0 dla natychmiastowego efektu
// (w Route Handlers nie możemy użyć updateTag)
revalidateTag('posts', { expire: 0 })
if (slug) {
revalidateTag(`post-${slug}`, { expire: 0 })
}
return Response.json({ revalidated: true })
}
Wzorzec 2: E-commerce — produkt z ceną czasu rzeczywistego
Ten wzorzec jest szczególnie ciekawy, bo pokazuje jak rozdzielić dane o różnej zmienności.
// lib/products.ts
import { cacheTag, cacheLife } from 'next/cache'
// Informacje o produkcie — zmienia się rzadko
export async function getProductDetails(id: string) {
'use cache'
cacheLife('days')
cacheTag('products', `product-${id}`)
return db.product.findUnique({
where: { id },
include: { images: true, specifications: true },
})
}
// Cena i dostępność — zmienia się często
export async function getProductPricing(id: string) {
'use cache'
cacheLife({
stale: 60, // 1 minuta na kliencie
revalidate: 30, // 30 sekund na serwerze
expire: 300, // 5 minut max
})
cacheTag(`pricing-${id}`)
return db.productPricing.findUnique({
where: { productId: id },
select: { price: true, salePrice: true, stock: true },
})
}
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { getProductDetails, getProductPricing } from '@/lib/products'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProductDetails(id)
if (!product) return <div>Produkt nie znaleziony</div>
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Cena ładowana niezależnie z krótszym cache */}
<Suspense fallback={<p>Ładowanie ceny...</p>}>
<ProductPricing productId={id} />
</Suspense>
</main>
)
}
async function ProductPricing({ productId }: { productId: string }) {
const pricing = await getProductPricing(productId)
return (
<div>
<span>{pricing?.price} zł</span>
{pricing?.stock === 0 && <span>Niedostępny</span>}
</div>
)
}
Widzisz, co się tu dzieje? Rozdzielamy dane produktowe na dwie funkcje z kompletnie różnymi profilami cache. Opisy, zdjęcia, specyfikacje — cachowane na dni, bo zmieniają się raz na ruski rok. Ceny i stany magazynowe — odświeżane co 30 sekund, bo to żyje. A Suspense pozwala wyświetlić informacje o produkcie natychmiast, ładując cenę asynchronicznie w tle.
Debugowanie cache: Narzędzia i techniki
Cachowanie jest niewidoczne — i to zarówno błogosławieństwo, jak i przekleństwo. Kiedy wszystko działa, jest pięknie. Kiedy coś nie gra, debugowanie potrafi przyprawić o ból głowy. Oto co mi pomaga.
Zmienne środowiskowe do debugowania
# Włącz szczegółowe logowanie cache
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
# Uruchom build produkcyjny lokalnie (cache działa inaczej w dev!)
npm run build && npm run start
I tu muszę krzyknąć: serwer deweloperski (next dev) nie odzwierciedla wiernie zachowania cache w produkcji. Powtórzę to jeszcze wielokrotnie w tym artykule, bo to pułapka, w którą wpadłem nie raz. Mnóstwo problemów z cachowaniem jest kompletnie niewidocznych w trybie deweloperskim. Zawsze, ale to zawsze testuj cache na buildzie produkcyjnym (next build && next start).
Logowanie w funkcjach z 'use cache'
W trybie deweloperskim logi z cachowanych funkcji wyświetlane są z prefiksem Cache, co ułatwia identyfikację, które wywołania trafiają do cache, a które generują świeże dane. Mały detal, ale niesamowicie przydatny przy diagnozie.
Najczęstsze pułapki
- Build zawiesza się z 'use cache' — klasyka. Prawdopodobnie odwołujesz się do Promise, który rozwiązuje się do danych dynamicznych lub runtime'owych, utworzonych poza granicą
'use cache'. Cachowana funkcja czeka na dane, które nie mogą zostać rozwiązane podczas budowania, co powoduje timeout po 50 sekundach. Zobaczysz komunikat: „Filling a cache during prerender timed out..." — i wtedy wiesz, gdzie szukać. - Dane runtime w scope 'use cache' —
cookies(),headers()i inne API runtime nie mogą być w tym samym zakresie co'use cache'. Rozwiązanie: wyodrębnij wartości z API runtime poza cachowaną funkcją i przekaż je jako argumenty. - Router Cache serwuje stare dane — pamiętasz te 30 sekund minimalnego stale na kliencie? No właśnie. Jeśli potrzebujesz natychmiastowego odświeżenia w UI po mutacji, użyj
updateTagw Server Actions albo wymuś odświeżenie przezrouter.refresh(). - Testowanie na dev zamiast na produkcji — powtarzam po raz kolejny, bo nie mogę wystarczająco tego podkreślić:
next devkłamie o cachowaniu. Zawsze weryfikuj nanext build && next start.
Migracja z unstable_cache
Jeśli korzystałeś z unstable_cache w starszych wersjach Next.js (a kto nie?), pora na migrację. Oficjalnie rekomendowane jest przejście na 'use cache' z cacheTag. Na szczęście migracja jest dość bezbolesna.
Przed (unstable_cache)
import { unstable_cache } from 'next/cache'
const getCachedPosts = unstable_cache(
async () => {
return db.post.findMany({ orderBy: { createdAt: 'desc' } })
},
['posts'],
{ revalidate: 3600, tags: ['posts'] }
)
Po ('use cache' + cacheTag)
import { cacheTag, cacheLife } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return db.post.findMany({ orderBy: { createdAt: 'desc' } })
}
Nowe podejście jest prostsze, bardziej deklaratywne i lepiej integruje się z resztą frameworka. Kompilator sam generuje klucze cache — koniec z ręcznym zarządzaniem tablicami kluczy. Profile cacheLife zastępują surowe wartości revalidate, a cacheTag daje precyzyjną inwalidację. Szczerze? Nie wiem, dlaczego tak długo siedzieliśmy na unstable API.
Najlepsze praktyki: Podsumowanie
Na koniec zestawienie najważniejszych zasad. Warto sobie to gdzieś zapisać albo przykleić karteczkę na monitor:
- Zacznij od dynamicznego renderowania — w Next.js 15/16 domyślnie nic nie jest cachowane. Dodawaj cachowanie tam, gdzie mierzalnie poprawia wydajność, zamiast cachować profilaktycznie. Mierz, nie zgaduj.
- Używaj tagów zamiast ścieżek —
cacheTagzrevalidateTag/updateTagjest znacznie elastyczniejszy niżrevalidatePath, szczególnie gdy dane pojawiają się na wielu stronach. - Definiuj semantyczne profile — zamiast magicznych liczb rozrzuconych po kodzie (
revalidate: 3600), twórz nazwane profile ('blog','produkty') wnext.config.ts. Przyszły Ty będzie wdzięczny. - Rozdzielaj dane o różnej zmienności — tak jak w przykładzie e-commerce. Opisy produktów i ceny to dwa różne światy, jeśli chodzi o cachowanie.
- Centralizuj tagi — jeden plik z eksportowanymi stałymi tagów. Zapobiega literówkom i daje jasny obraz strategii inwalidacji.
- Testuj na buildzie produkcyjnym — cache działa fundamentalnie inaczej w
next devniż wnext build && next start. Mówię to po raz ostatni. (Nie, kłamię, pewnie powiem jeszcze.) - Monitoruj w produkcji — używaj
NEXT_PRIVATE_DEBUG_CACHE, Vercel Analytics lub niestandardowych nagłówków cache. Cachowanie to nie „fire and forget".
FAQ — Najczęściej zadawane pytania
Jaka jest różnica między revalidateTag a updateTag w Next.js?
revalidateTag (z profilem 'max') oznacza cache jako przestarzały i odświeża dane w tle przy następnym żądaniu — użytkownik natychmiast dostaje starą wersję, a nowa tworzy się w tle. updateTag natychmiast wygasza cache i wymusza czekanie na świeże dane. W skrócie: updateTag w Server Actions, gdy użytkownik musi od razu zobaczyć efekt zmiany (edycja profilu, aktualizacja koszyka). revalidateTag do webhooków i aktualizacji w tle, gdzie ta sekunda opóźnienia nikogo nie boli.
Czy 'use cache' zastępuje ISR w Next.js?
Nie do końca. ISR nadal działa przez parametr revalidate w opcjach fetch albo route segment config. Ale dyrektywa 'use cache' z cacheLife to nowsze, bardziej elastyczne podejście — pozwala cachować nie tylko fetche, ale też zapytania do bazy, obliczenia i całe komponenty. W nowych projektach zdecydowanie idź w 'use cache'.
Dlaczego moje cachowane dane nie odświeżają się w trybie deweloperskim?
Bo next dev kłamie. (Tak, znowu to mówię.) Serwer deweloperski nie odzwierciedla wiernie zachowania cache w produkcji — wiele mechanizmów jest wyłączonych lub działa inaczej. Jedyny sposób na rzetelną weryfikację: npm run build && npm run start. Opcjonalnie włącz verbose logowanie zmienną NEXT_PRIVATE_DEBUG_CACHE=1.
Czy mogę używać cookies() i headers() wewnątrz 'use cache'?
Nie. I to nie podlega dyskusji — runtime API jak cookies(), headers() czy searchParams nie mogą żyć w tym samym zakresie co 'use cache'. Rozwiązanie jest proste: wyodrębnij potrzebne wartości poza cachowaną funkcją i przekaż je jako argumenty. Argumenty automatycznie stają się częścią klucza cache, więc wszystko działa jak powinno.
Jak wybrać odpowiedni profil cacheLife dla mojej aplikacji?
Zadaj sobie dwa pytania: jak często dane się zmieniają i jakie opóźnienie w aktualizacji jest akceptowalne? Regulamin, strona „O nas"? 'weeks' albo 'max'. Blog, artykuły? 'hours' lub 'days' plus on-demand rewalidacja tagami. Ceny, stany magazynowe? 'seconds' lub 'minutes'. A potem zdefiniuj własne nazwane profile w next.config.ts, żeby nie mieć magicznych liczb rozsypanych po całym projekcie.