Caching in Next.js 15 App Router: Data Cache, use cache Directive en dynamicIO Compleet Uitgelegd

Next.js 15 heeft het cachingmodel fundamenteel omgegooid: fetch() is niet langer gecachet by default. Leer met use cache, cacheLife, cacheTag, dynamicIO en revalidateTag volledige controle te krijgen over je Data Cache, Full Route Cache en Router Cache.

Next.js 15 Caching 2026: use cache Directive Gids

Eerlijk gezegd was de overstap naar Next.js 15 voor mij eerst even slikken. Het cachinggedrag werd fundamenteel omgegooid: waar eerdere versies agressief cachten by default, is caching in Next.js 15 standaard uitgeschakeld. Dat betekent dat fetch(), GET Route Handlers en de Client Router Cache nu allemaal opt-in zijn. Een behoorlijke mindshift, maar — zoals ik hieronder uitleg — uiteindelijk een hele welkome.

Voor ontwikkelaars komt het hierop neer: je moet nu expliciet aangeven wat er gecachet mag worden. En daar heb je een handvol nieuwe tools voor gekregen, namelijk de use cache directive, cacheLife, cacheTag, en het experimentele dynamicIO mechanisme.

In deze gids loop ik door alle drie de cache lagen, laat ik zien wanneer je welke primitive pakt, en hoe je invalidatie netjes regelt met revalidatePath en revalidateTag. Alle codevoorbeelden zijn getest tegen Next.js 15.4 en komen uit patronen die ik zelf in productie draai.

De drie cache lagen in Next.js 15

Next.js 15 hanteert drie cachingmechanismen, elk met een eigen doel en levenscyclus. Het verschil hiertussen snappen is eigenlijk de basis voor alles wat volgt — dus neem er even de tijd voor.

1. Data Cache (server-side)

De Data Cache slaat resultaten op van server-side fetch() aanroepen en unstable_cache() functies. Deze cache overleeft deployments en wordt gedeeld over al je gebruikers heen. Zoals gezegd: in Next.js 15 is ze niet langer standaard actief voor fetch(). Je moet het expliciet aanzetten via next: { revalidate } of next: { tags }.

2. Full Route Cache (server-side)

De Full Route Cache bewaart de geprerenderde HTML- en RSC-payload van statische routes, ofwel tijdens build time, ofwel bij het eerste bezoek. Ideaal voor pagina's die zelden veranderen: marketingpagina's, documentatie, blogposts — dat soort werk.

3. Router Cache (client-side)

De Router Cache is een in-memory cache in de browser die navigaties versnelt via prefetching. Sinds Next.js 15 is ook deze cache uncached-by-default. Dat voorkomt het irritante scenario waarbij gebruikers verouderde data zien na een navigatie terug (iets waar ik zelf in v14 vaker over gestruikeld ben dan ik wil toegeven).

Waarom Next.js 15 het cachingmodel omkeerde

In Next.js 13 en 14 was caching agressief en impliciet: elke fetch() werd automatisch gecachet. Dat leidde tot eindeloze “waarom is mijn API stale?”-vragen op Discord en onvoorspelbaar gedrag tussen omgevingen. Het Vercel-team heeft er naar geluisterd en koos in versie 15 voor expliciet boven impliciet.

Die filosofie sluit mooi aan bij hoe React zelf 'use client' en 'use server' introduceerde: grenzen markeer je in de code, niet verstopt in framework-gedrag. Ik vind het persoonlijk een enorme verbetering, al kost de migratie wel wat denkwerk.

De use cache directive: expliciete caching per functie of component

De use cache directive is zo'n beetje het nieuwe hart van caching in Next.js 15. Het werkt vergelijkbaar met 'use server' of 'use client': je zet de string bovenaan een functie, component of bestand, en de return-waarde wordt gecachet. Simpel concept, krachtige gevolgen.

Caching activeren in next.config.ts

Voordat je use cache überhaupt kunt gebruiken, moet je dynamicIO (of minimaal useCache) inschakelen:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
    // Of, als je alleen use cache wilt zonder dynamicIO:
    // useCache: true,
  },
}

export default nextConfig

Caching op functieniveau (data-level)

Zet use cache bovenaan een async functie en het resultaat wordt gecachet:

// lib/products.ts
import { db } from '@/lib/db'

export async function getProducts() {
  'use cache'

  return db.query('SELECT * FROM products WHERE published = true')
}

export async function getProductById(id: string) {
  'use cache'

  return db.query('SELECT * FROM products WHERE id = $1', [id])
}

Elke aanroep met dezelfde argumenten krijgt hetzelfde gecachete resultaat terug. De cache-key wordt automatisch afgeleid uit de functieargumenten, dus let op: die moeten serialiseerbaar zijn.

Caching op componentniveau (UI-level)

Je kunt ook hele Server Components cachen. Dat scheelt vaak een hoop boilerplate:

// app/components/ProductGrid.tsx
export default async function ProductGrid({ category }: { category: string }) {
  'use cache'

  const products = await fetch(
    `https://api.example.com/products?category=${category}`
  ).then(res => res.json())

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((p: Product) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  )
}

Een hele pagina cachen

En ja — je kunt ook gewoon 'use cache' bovenaan je page.tsx zetten om de hele route te cachen:

// app/blog/page.tsx
'use cache'

import { getPosts } from '@/lib/posts'

export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <main>
      <h1>Blog</h1>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </main>
  )
}

Cache-levensduur regelen met cacheLife

Met cacheLife bepaal je hoe lang een use cache entry geldig blijft. Er zijn drie waarden: stale (client), revalidate (server) en expire (harde vervaltijd). Verwarrend? Een beetje, maar in de praktijk kom je meestal uit met de ingebouwde profielen.

Ingebouwde profielen

Next.js levert standaardprofielen mee: seconds, minutes, hours, days, weeks en max.

import { cacheLife } from 'next/cache'

export async function getLatestNews() {
  'use cache'
  cacheLife('minutes')

  return fetch('https://api.news.com/latest').then(r => r.json())
}

export async function getStaticPricing() {
  'use cache'
  cacheLife('days')

  return db.query('SELECT * FROM pricing_plans')
}

Aangepaste profielen

Heb je specifieke wensen? Definieer dan eigen profielen in next.config.ts. Handig voor herbruikbare cachingstrategieën door je hele app heen:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      productCatalog: {
        stale: 60 * 5,          // 5 minuten client-side
        revalidate: 60 * 60,    // 1 uur server revalidation
        expire: 60 * 60 * 24,   // 24 uur hard expiry
      },
      userDashboard: {
        stale: 30,
        revalidate: 60,
        expire: 60 * 10,
      },
    },
  },
}

En dan gebruik je ze zo:

export async function getCatalog() {
  'use cache'
  cacheLife('productCatalog')

  return db.query('SELECT * FROM products')
}

Targeted invalidatie met cacheTag

Met cacheTag markeer je cache-entries zodat je ze later gericht kunt invalideren via revalidateTag. Dit is wat mij betreft de krachtigste vorm van cache-invalidatie: je kunt in één klap alle afhankelijke pagina's verversen zonder exact te weten waar je data opduikt.

import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('posts', `post-${slug}`)

  return db.query('SELECT * FROM posts WHERE slug = $1', [slug])
}

export async function getAllPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  return db.query('SELECT * FROM posts ORDER BY published_at DESC')
}

Time-based revalidation met fetch

Voor legacy-projecten of simpele scenario's werkt de klassieke fetch caching nog steeds prima — alleen nu expliciet opt-in:

// Cache 1 uur (ISR-stijl)
const res = await fetch('https://api.example.com/news', {
  next: { revalidate: 3600 },
})

// Cache met tag voor on-demand invalidatie
const res = await fetch('https://api.example.com/posts', {
  next: {
    revalidate: 3600,
    tags: ['posts'],
  },
})

// Volledig dynamisch (geen cache)
const res = await fetch('https://api.example.com/user', {
  cache: 'no-store',
})

Je kunt ook op segmentniveau een default zetten:

// app/blog/page.tsx
export const revalidate = 3600 // 1 uur

export default async function BlogPage() {
  // Alle fetches op deze route gebruiken 3600s als default revalidate
}

On-demand revalidation: revalidatePath vs revalidateTag

Na een mutatie — een formulier submit, een webhook, een admin-actie — wil je meestal direct de cache invalideren. Hiervoor heb je twee complementaire primitives. Welke je pakt, hangt af van hoeveel je wil wegblazen.

revalidatePath gebruiken

revalidatePath invalideert alle gecachete data voor een specifiek pad. Ideaal als je niet weet welke tags er allemaal aan gekoppeld zijn, of als je gewoon een hele route wil verversen.

'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

export async function updatePost(id: string, data: PostInput) {
  await db.update('posts', id, data)

  // Invalideer een specifieke blogpost
  revalidatePath(`/blog/${id}`)
}

export async function deleteAllPosts() {
  await db.query('DELETE FROM posts')

  // Invalideer alle dynamische routes die deze layout delen
  revalidatePath('/blog/[slug]', 'layout')
}

export async function purgeEverything() {
  // Purged de hele Client Router Cache en serverdata
  revalidatePath('/', 'layout')
}

revalidateTag gebruiken

revalidateTag invalideert alle data met een bepaalde tag, ongeacht op welke pagina die data wordt gebruikt. Preciezer, en je voorkomt over-invalidatie. Dit is negen van de tien keer wat je wil.

'use server'

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await db.insert('posts', {
    title: formData.get('title'),
    content: formData.get('content'),
  })

  // Ververs alle pagina's die 'posts' gebruiken
  revalidateTag('posts')

  return post
}

export async function updatePost(slug: string, data: PostInput) {
  await db.update('posts', { slug }, data)

  // Specifieke post + lijst tegelijk
  revalidateTag(`post-${slug}`)
  revalidateTag('posts')
}

updateTag voor read-your-own-writes

In Server Actions kun je updateTag gebruiken om onmiddellijk stale data te verwijderen voor de huidige gebruiker (het zogeheten read-your-own-writes patroon):

'use server'

import { updateTag } from 'next/cache'

export async function submitComment(postId: string, text: string) {
  await db.insert('comments', { postId, text })

  // Gebruiker ziet direct zijn eigen comment, anderen zien stale data tot revalidate
  updateTag(`comments-${postId}`)
}

dynamicIO: detectie van uncached async operaties

Zet je dynamicIO: true aan, dan vereist Next.js dat elke asynchrone I/O-operatie óf gecachet is (via use cache) óf gewrapped wordt in <Suspense>. Klinkt streng, maar dwingt je tot expliciete keuzes — en dat is precies wat je wil in een serieuze codebase.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats'
import { getStaticContent } from '@/lib/content'

export default async function DashboardPage() {
  // Gecachet, dus toegestaan buiten Suspense
  const content = await getStaticContent()

  return (
    <div>
      <h1>{content.title}</h1>

      {/* Uncached, dynamische data MOET in Suspense */}
      <Suspense fallback={<p>Laden...</p>}>
        <UserStats />
      </Suspense>
    </div>
  )
}

Heb je dynamicIO aan staan en zet je uncached data buiten Suspense? Dan krijg je tijdens build de foutmelding: “Uncached data was accessed outside of <Suspense>. Vervelend de eerste keer, maar het dwingt je naar een Partial Prerendering-vriendelijke architectuur — dus eigenlijk een cadeautje.

Geneste use cache directives

Roept een gecachete functie een andere gecachete functie aan, dan geldt: de buitenste cache bepaalt de uiteindelijke levensduur. Een expliciete cacheLife in de buitenste scope wint altijd.

async function getAuthor(id: string) {
  'use cache'
  cacheLife('days')
  return db.query('SELECT * FROM authors WHERE id = $1', [id])
}

async function getPostWithAuthor(slug: string) {
  'use cache'
  cacheLife('minutes') // Wint van 'days' van de inner cache

  const post = await db.query('SELECT * FROM posts WHERE slug = $1', [slug])
  const author = await getAuthor(post.author_id)

  return { ...post, author }
}

Bij een cache hit van de buitenste functie komt de volledige geneste output (inclusief author) uit de cache. De inner cacheLife('days') doet alleen iets als je getAuthor direct aanroept buiten getPostWithAuthor.

Debuggen: de x-nextjs-cache header

Next.js plaatst een x-nextjs-cache response header die je vertelt wat er met de cache is gebeurd. Houd deze er even bij tijdens debuggen, het scheelt een hoop giswerk:

  • HIT — geserveerd uit de cache
  • STALE — uit cache, wordt op de achtergrond ververst (stale-while-revalidate)
  • MISS — niet in cache, fresh gerenderd
  • REVALIDATED — opnieuw gegenereerd via on-demand revalidation
curl -I https://jouwsite.nl/blog/post-1
# HTTP/2 200
# x-nextjs-cache: HIT

Praktisch patroon: blog met volledige cachingstrategie

Om het concreet te maken, hier een realistisch patroon voor een blogsectie met een solide caching-setup:

// lib/blog.ts
import { cacheLife, cacheTag } from 'next/cache'
import { db } from './db'

export async function getAllPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  return db.query(
    'SELECT id, slug, title, excerpt, published_at FROM posts ORDER BY published_at DESC'
  )
}

export async function getPostBySlug(slug: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('posts', `post-${slug}`)

  return db.query('SELECT * FROM posts WHERE slug = $1', [slug])
}

// app/blog/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function publishPost(slug: string, data: PostInput) {
  await db.update('posts', { slug }, { ...data, published: true })

  revalidateTag(`post-${slug}`)
  revalidateTag('posts')
}

Veelgemaakte fouten en hoe je ze voorkomt

Fout 1: Cache verwachten zonder dynamicIO

De use cache directive werkt alleen als dynamicIO of useCache is ingeschakeld in next.config.ts. Zonder die flag wordt de directive gewoon genegeerd — en ja, zonder waarschuwing. Vraag me niet hoe ik dat weet.

Fout 2: Niet-serialiseerbare argumenten

Cache keys worden afgeleid uit de argumenten. Geef dus geen Date objects, Maps of classes door. Gebruik primitives:

// Slecht: Date object als key is onvoorspelbaar
async function getEvents(from: Date) { 'use cache' /* ... */ }

// Goed: ISO string
async function getEvents(fromIso: string) { 'use cache' /* ... */ }

Fout 3: cookies() of headers() in gecachete functies

Gecachete functies mogen geen request-specifieke data gebruiken, zoals cookies() of headers(). Wrap dat in een uncached wrapper en roep de cache aan met expliciete argumenten.

Fout 4: revalidatePath('/', 'layout') misbruiken

Deze aanroep blaast je hele site weg qua cache. Gebruik 'm alleen na grote migraties of admin-acties, echt niet na elke mutatie. Anders sloop je al je performance-winst in één keer.

Beslissingsboom: welke cachingstrategie kies je?

  1. Is de data volledig statisch (marketingpagina)? → use cache met cacheLife('weeks') op pagina-niveau
  2. Data verandert periodiek zonder mutaties? → fetch({ next: { revalidate: N } }) of cacheLife('hours')
  3. Data wordt door admin/gebruiker gemuteerd? → cacheTag + revalidateTag in Server Action
  4. Data is per-gebruiker (auth, winkelwagen)? → Geen cache, plus <Suspense> wrap bij dynamicIO
  5. Read-your-own-writes? → updateTag in Server Action

Veelgestelde vragen

Waarom cachet fetch() niet meer standaard in Next.js 15?

Vercel heeft de defaults omgedraaid omdat ontwikkelaars te vaak verrast werden door impliciete caching. In Next.js 13/14 leidde dat tot stale data in productie terwijl lokaal alles werkte. Opt-in caching maakt het gedrag voorspelbaar en expliciet — een correctie die, kijkend naar de GitHub-issues destijds, hoog nodig was.

Wat is het verschil tussen use cache en unstable_cache?

unstable_cache is de oudere API die nog steeds werkt, maar de use cache directive is de aanbevolen nieuwe aanpak. use cache integreert beter met dynamicIO, ondersteunt componenten en hele pagina's, en heeft een eenvoudigere syntax. Nieuwe projecten zouden direct voor use cache moeten gaan.

Werkt use cache in Edge Runtime?

Ja, use cache werkt zowel in Node.js als in Edge Runtime. De Data Cache zelf wordt door Vercel gehost via hun globale cachingnetwerk en is voor beide runtimes beschikbaar.

Hoe combineer ik revalidatePath en revalidateTag?

Ze zijn complementair. Gebruik revalidateTag als je primaire invalidatie-strategie (precies en efficiënt) en revalidatePath voor edge cases waar je een hele route wilt forceren — denk aan layout-wijzigingen of grootschalige content-updates.

Moet ik dynamicIO meteen inschakelen?

Voor nieuwe projecten: ja, gewoon doen. Voor bestaande projecten: doe het gefaseerd. dynamicIO is strikter en vereist dat uncached async operaties in Suspense staan. Begin met alleen useCache: true, migreer componenten één voor één, en schakel dynamicIO pas in als je codebase er klaar voor is.

Conclusie

Caching in Next.js 15 is krachtiger, maar vereist ook meer intentie — en dat is uiteindelijk maar goed ook. Met de use cache directive, cacheLife, cacheTag en de helder gescheiden Data/Full Route/Router cache lagen heb je fine-grained controle over wat wel en niet gecachet wordt. Combineer dat met revalidateTag voor on-demand invalidatie en je zit op een architectuur die zowel razendsnel als altijd up-to-date aanvoelt.

Mijn advies: begin klein. Pak een read-heavy endpoint, wrap de data-functie met use cache + cacheTag, en hang revalidateTag aan de bijbehorende Server Action. Meet vervolgens je x-nextjs-cache headers en breid van daaruit rustig uit over de rest van je applicatie. Een week later snap je niet meer hoe je het ooit anders deed.

Over de Auteur Editorial Team

Our team of expert writers and editors.