Complete gids: Next.js cache lagen en GraphQL optimalisatie

Next.js implementeert vier verschillende cache lagen. Begrijpen hoe deze werken - en waarom GraphQL queries en ORM calls zich anders gedragen dan fetch() requests - is essentieel voor het bouwen van snelle websites.

De vier cache lagen

Dit artikel beschrijft de cache zoals die werkt in Next.js 14.

Next.js 15 veranderingen: Vanaf Next.js 15 is fetch() niet meer standaard gecached. Je moet expliciet { cache: 'force-cache' } gebruiken om cache in te schakelen. De vier cache lagen en de concepten in dit artikel blijven wel geldig, maar de standaard instellingen zijn veranderd.

Next.js 16 veranderingen: Next.js 16 introduceert een compleet nieuw cache model met "Cache Components" en de "use cache" directive. In Next.js 16 wordt niets automatisch gecached - je moet expliciet aangeven wat gecached moet worden. Hoewel unstable_cache nog steeds ondersteund wordt, is "use cache" de nieuwe aanbevolen aanpak. Hiervoor moet je cacheComponents: true in je next.config toevoegen. Next.js gebruikt meerdere cache mechanismen die elk een specifiek doel dienen:

  1. Request Memoization - Automatische deduplicatie tijdens een render
  2. Data Cache - Persistent cache van fetch requests
  3. Full Route Cache - Statische pagina cache
  4. Router Cache - Client-side navigatie cache

Laten we elk van deze lagen in detail bekijken.

Request Memoization

Request Memoization is React's ingebouwde mechanisme om duplicate requests tijdens één render te voorkomen.

Een praktisch voorbeeld: stel je gebruikt dezelfde data fetching functie zowel voor metadata als voor je pagina content:

export async function generateMetadata() {
  const data = await fetchData(id);
  
  return {
    title: data.title,
    description: data.description
  };
}

export default async function Page() {
  const data = await fetchData(id);
  
  return <Article data={data} />;
}

Wanneer fetchData dezelfde parameters heeft, wordt de functie maar één keer uitgevoerd tijdens dezelfde render, ook al roep je hem twee keer aan.

Data Cache

De Data Cache is Next.js's persistent cache voor data fetching. De cache blijft bestaan tussen requests en zelfs tussen deployments. Responses worden hier automatisch opgeslagen en hergebruikt.

Full Route Cache

De Full Route Cache slaat complete gerenderde routes op - zowel de HTML als de RSC (React Server Component) payload. Deze cache wordt tijdens build time gegenereerd voor statische routes, of on-demand voor dynamische routes met revalidation.

// Statische route - wordt tijdens build gecached
export default async function Page() {
  const data = await fetchData();
  return <Content data={data} />;
}

// Dynamische route met revalidation
export const revalidate = 3600; // 1 uur

export default async function Page() {
  const data = await fetchData();
  return <Content data={data} />;
}

Deze cache is onafhankelijk van hoe je data fetched. De uiteindelijke gerenderde pagina kan in de Full Route Cache terechtkomen, ongeacht welke data fetching methode je gebruikt.

Router Cache

De Router Cache is een client-side cache die Next.js gebruikt voor navigatie tussen paginas. Wanneer een gebruiker door je applicatie navigeert, slaat Next.js de RSC payload tijdelijk op in het geheugen van de browser.

Deze cache zorgt voor instant navigatie bij terug- en vooruit-navigeren, maar heeft een relatief korte levensduur (standaard 30 seconden voor dynamische routes, 5 minuten voor statische routes).

Waarom GraphQL en ORMs anders werken

Request Memoization en Data Cache werken niet automatisch voor GraphQL queries en ORM calls. Ze werken alleen out-of-the-box voor standaard fetch() requests met GET. Dit verschil zit in hoe Next.js bepaalt wat er gecached moet worden.

De GET vs POST

HTTP GET requests zijn per definitie idempotent - ze halen data op zonder side effects. Next.js gebruikt dit als signaal dat de response veilig gecached kan worden.

POST requests daarentegen worden gezien als mutaties - operaties die data veranderen. Next.js cached deze standaard niet, omdat je altijd de meest recente staat wilt zien na een mutatie.

// GET - wordt gecached
fetch('https://api.example.com/posts', { method: 'GET' });

// POST - wordt NIET gecached  
fetch('https://api.example.com/posts', { 
  method: 'POST',
  body: JSON.stringify(data)
});

Hetzelfde resultaat, verschillende aanpak

Laten we kijken naar een praktisch voorbeeld: het ophalen van een lijst blogposts. Met fetch() en GET krijg je automatische caching:

// REST API met fetch - Request Memoization én Data Cache automatisch
export async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { 
      revalidate: 3600,
      tags: ['posts']
    }
  });
  return response.json();
}

// Gebruik in generateMetadata
const posts = await getPosts(); // Hit de API

// Gebruik in Page component
const posts = await getPosts(); // Gebruikt Request Memoization (zelfde render)

// Later, nieuwe request
const posts = await getPosts(); // Gebruikt Data Cache

// Cache invalidatie
revalidateTag('posts'); // Invalideert de Data Cache

Next.js regelt beide cache lagen automatisch - Request Memoization binnen één render, Data Cache tussen requests.

Dezelfde functionaliteit met GraphQL vereist expliciete configuratie:

import { cache } from 'react';
import { unstable_cache } from 'next/cache';

// GraphQL - handmatig beide lagen toevoegen
export const getPosts = cache(async () => {  // Request Memoization
  return await unstable_cache(               // Data Cache
    async () => graphqlClient.query(GET_POSTS_QUERY),
    ['posts'],
    { 
      revalidate: 3600,
      tags: ['posts']
    }
  )();
});

// Gebruik in generateMetadata
const posts = await getPosts(); // Hit de API

// Gebruik in Page component  
const posts = await getPosts(); // Gebruikt Request Memoization

// Later, nieuwe request
const posts = await getPosts(); // Gebruikt Data Cache

// Cache invalidatie
revalidateTag('posts'); // Invalideert de Data Cache

Het eindresultaat is hetzelfde - revalidate en tags werken identiek - maar je moet beide lagen expliciet wrappen.

Hetzelfde patroon geldt voor ORMs:

// Prisma - handmatig beide lagen toevoegen
export const getPosts = cache(async () => {
  return await unstable_cache(
    async () => prisma.post.findMany(),
    ['posts'],
    { 
      revalidate: 3600,
      tags: ['posts']
    }
  )();
});

Waarom GraphQL POST gebruikt

GraphQL gebruikt POST voor alle requests, ook voor queries die conceptueel gewoon data ophalen. Dit is een protocol keuze - queries kunnen complex worden en URL-lengte limitaties overschrijden.

Maar Next.js ziet alleen een POST request en behandelt deze als een potentiële mutatie. Het framework kan niet onderscheiden tussen een GraphQL query en een GraphQL mutation.

Waarom ORMs de cache omzeilen

ORMs zoals Prisma, Drizzle of TypeORM gebruiken hun eigen APIs die niet via fetch() lopen:

// Prisma maakt een directe database connectie
const posts = await prisma.post.findMany();

// Drizzle ook
const posts = await db.select().from(postsTable);

Deze calls gaan rechtstreeks naar de database zonder de fetch() API te gebruiken, dus Next.js's automatische cache wordt nooit geactiveerd.

Zelf cache implementeren

Wanneer je geen GET requests gebruikt, verlies je het automatische caching voordeel van Next.js. Dit betekent niet dat je applicatie langzamer moet zijn - je moet alleen zelf de verantwoordelijkheid nemen voor cache die Next.js normaal automatisch regelt.

React's cache() voor Request Memoization

React's cache() zorgt voor deduplicatie binnen één render:

import { cache } from 'react';

export const getPost = cache(async (id: string) => {
  return await graphqlClient.query(GET_POST, { id });
});

Next.js's unstable_cache voor Data Cache

unstable_cache zorgt voor persistente cache tussen requests:

import { unstable_cache } from 'next/cache';

export const getCachedPost = unstable_cache(
  async (id: string) => {
    return await graphqlClient.query(GET_POST, { id });
  },
  ['post'],
  { revalidate: 3600, tags: ['posts'] }
);

Beide lagen combineren

In de praktijk combineer je vaak beide mechanismen voor optimale cache:

import { cache } from 'react';
import { unstable_cache } from 'next/cache';

export const getPost = cache(async (id: string) => {
  return await unstable_cache(
    async () => graphqlClient.query(GET_POST, { id }),
    [`post-${id}`],
    { revalidate: 3600, tags: ['posts'] }
  )();
});

Nu krijg je:

  • Request deduplicatie binnen één render (via cache())
  • Persistent cache tussen requests (via unstable_cache())
  • Controleerbare revalidation en cache invalidation

Cache key strategieën

De cache key array in unstable_cache is cruciaal - deze moet uniek zijn voor elke variant van je data:

// Basis cache key
unstable_cache(fetchPosts, ['posts'], options);

// Met parameters - key moet variabelen bevatten
unstable_cache(
  async (status: string) => fetchPosts(status),
  ['posts', status],
  options
);

// Met meerdere parameters
unstable_cache(
  async (userId: string, limit: number) => fetchUserPosts(userId, limit),
  ['user-posts', userId, limit.toString()],
  options
);

Elke unieke combinatie van parameters moet resulteren in een unieke cache key, anders krijg je verkeerde data terug uit de cache.

Cache invalidation

Een groot voordeel van unstable_cache is de mogelijkheid om caches targeted te invalideren met tags:

import { revalidateTag } from 'next/cache';

// In je API route of Server Action na een mutatie
export async function updatePost(id: string, data: PostData) {
  await graphqlClient.mutate(UPDATE_POST, { id, data });
  
  // Invalideert alle caches met de 'posts' tag
  revalidateTag('posts');
}

Dit is vergelijkbaar met hoe Next.js automatisch fetch caches kan revalideren, maar nu met volledige controle over wanneer en welke caches ge-invalideerd worden.

Wanneer niet te cachen

Niet elke situatie vereist caching. Overweeg om unstable_cache over te slaan bij:

Real-time of snel veranderende data:

// Geen Data Cache - altijd verse data
export const getLiveScore = cache(async (matchId: string) => {
  return await graphqlClient.query(GET_LIVE_SCORE, { matchId });
});

User-specifieke content:

// Alleen Request Memoization, geen persistente cache
export const getUserCart = cache(async (userId: string) => {
  return await prisma.cart.findUnique({ where: { userId } });
});

De trade-off is altijd: toegevoegde complexiteit versus daadwerkelijke performance winst. Meet eerst, optimaliseer daarna.

Praktische implicaties

Het begrijpen van deze cache lagen helpt je betere beslissingen te maken over data fetching en performance optimalisatie.

Wanneer welke laag actief is

Request Memoization werkt alleen met cache() wrapper. Voor fetch() met GET is dit automatisch, voor GraphQL en ORMs moet je dit expliciet toevoegen.

Data Cache werkt alleen voor fetch() met GET, of expliciet via unstable_cache voor GraphQL en ORMs.

Full Route Cache werkt onafhankelijk van je data fetching methode. Een pagina met GraphQL queries kan nog steeds volledig gecached worden als statische route of met revalidate.

Router Cache is altijd actief op de client, maar heeft weinig impact op server-side data fetching beslissingen.

Bewust omzeilen van cache

Soms wil je juist niet dat data gecached wordt:

// Expliciet no-cache voor fetch
fetch(url, { cache: 'no-store' });

// Of per route
export const dynamic = 'force-dynamic';

Dit is relevant voor user-specifieke data, real-time content, of wanneer je zeker wilt zijn van verse data.

Performance overwegingen

De Data Cache kan een enorme performance boost geven omdat responses niet opnieuw gefetched hoeven te worden. Voor GraphQL en ORM gebruikers betekent dit:

  • Request Memoization (cache()) voor deduplicatie binnen één render
  • unstable_cache voor persistent cache tussen requests
  • Full Route Cache met revalidate voor statische of semi-statische pagina's
  • Externe cache (Redis, CDN) als aanvullende laag
  • Database query optimalisatie en connection pooling

Het belangrijkste is om te begrijpen welke cache lagen actief zijn voor jouw specifieke setup, zodat je bewuste keuzes kunt maken over waar en wanneer je optimaliseert.

Conclusie

Next.js's cache architectuur bestaat uit vier afzonderlijke lagen die elk een specifiek doel dienen. Voor ontwikkelaars die fetch() met GET requests gebruiken, werkt veel van deze cache automatisch. Maar zodra je GraphQL of een ORM introduceert, moet je zelf de regie nemen over cache strategieën.

Het goede nieuws: met cache() en unstable_cache heb je dezelfde mogelijkheden als de automatische cache, met als bonus volledige controle over wat er gecached wordt en voor hoe lang. De trade-off is toegevoegde complexiteit - je moet bewust nadenken over cache keys, revalidation en invalidation.

Begrijp welke lagen actief zijn in jouw applicatie, meet waar performance problemen zitten, en optimaliseer vervolgens gericht. Niet elke query heeft persistent cache nodig, maar voor die gevallen waar het wel impact maakt, bieden Next.js's tools alle flexibiliteit die je nodig hebt.