Caching e Revalidazione in Next.js 16: use cache, ISR e Invalidazione On-Demand

Dalla direttiva use cache con cacheLife e cacheTag, all'ISR con revalidazione temporale, all'invalidazione on-demand con revalidateTag e updateTag. Pattern di produzione, cache privata e remota, e gli errori più comuni da evitare.

Introduzione: La Cache come Pilastro dell'Architettura Server-First

Nei tre articoli precedenti di questa serie abbiamo costruito, pezzo dopo pezzo, un'architettura server-first completa con Next.js 16. Siamo partiti dallo Streaming e Partial Prerendering, poi abbiamo attraversato le Server Actions con validazione e pattern avanzati, e infine ci siamo addentrati in Middleware e Proxy per il controllo delle richieste al confine della rete. Adesso tocca al quarto pilastro — e onestamente, è quello che genera più confusione tra gli sviluppatori: il caching.

Chi ha lavorato con Next.js 14 ricorderà bene la frustrazione: la cache era implicita, ogni fetch() veniva memorizzata in automatico, e per evitarlo dovevi aggiungere cache: "no-store". Quanti bug sottili ha causato questo comportamento in produzione? Troppi. Next.js 15 ha cominciato a cambiare rotta con il flag dynamicIO, e Next.js 16 chiude il cerchio: la cache è ora completamente esplicita e opt-in, gestita dalla direttiva "use cache" e dal flag cacheComponents in next.config.ts.

In questa guida esploreremo ogni livello di cache, le nuove API di invalidazione, i pattern di produzione e gli errori più comuni. Dopo averla letta, avrete il pieno controllo sulla cache della vostra app Next.js 16.

I Livelli di Cache in Next.js: Anatomia Completa

Prima di mettere le mani nel codice, c'è una cosa fondamentale da capire: Next.js non ha una cache, ma quattro livelli distinti che lavorano in sinergia. Ogni richiesta li attraversa in ordine, e capire dove i dati vengono intercettati è la chiave per evitare quei comportamenti "ma perché non si aggiorna?!" che tutti abbiamo vissuto almeno una volta.

1. Request Memoization (In-Memory, Singolo Render)

Quando più componenti nella stessa richiesta chiamano fetch() con lo stesso URL e le stesse opzioni, Next.js deduplica automaticamente le chiamate. Avviene tutto in memoria, dura solo per il ciclo di vita di un singolo render server-side e non richiede nessuna configurazione. È il livello più trasparente — e quello che crea meno problemi.

2. Data Cache (Persistente, Cross-Request)

Il Data Cache memorizza i risultati delle fetch() in modo persistente tra richieste diverse. In Next.js 16, questo livello non è più attivo di default: va attivato esplicitamente con "use cache" o con revalidate a livello di fetch. I dati persistono nel file system (o in un cache handler custom come Redis) e possono sopravvivere ai deploy se configurati bene.

3. Full Route Cache (HTML + RSC Payload)

Quando una rotta è completamente statica o usa "use cache", Next.js memorizza sia l'HTML renderizzato che il payload RSC. In pratica le richieste successive non devono nemmeno eseguire il codice dei componenti: il server restituisce direttamente il risultato pre-renderizzato. Velocissimo.

4. Router Cache (Client-Side)

Sul lato client, Next.js mantiene una cache in memoria dei segmenti di rotta già visitati. Ottimizza la navigazione tra pagine, ma può anche diventare fonte di confusione quando i dati cambiano sul server e il client continua a mostrare la versione vecchia. Vedremo più avanti come gestirla con router.refresh().

Ecco un diagramma del flusso di una richiesta attraverso tutti i livelli:

Richiesta dell'utente
       │
       ▼
┌─────────────────────┐
│   Router Cache       │  ← Client-side (in-memory, segmenti già visitati)
│   (Client)           │
└──────────┬──────────┘
           │ MISS
           ▼
┌─────────────────────┐
│   Full Route Cache   │  ← Server-side (HTML + RSC payload pre-renderizzato)
│   (Server)           │
└──────────┬──────────┘
           │ MISS
           ▼
┌─────────────────────┐
│   Data Cache         │  ← Server-side (persistente, risultati fetch/use cache)
│   (Server)           │
└──────────┬──────────┘
           │ MISS
           ▼
┌─────────────────────┐
│  Request Memoization │  ← Server-side (in-memory, singolo render, dedup)
│   (Server)           │
└──────────┬──────────┘
           │ MISS
           ▼
    Sorgente Dati
  (Database, API, CMS)

La Direttiva use cache: Il Nuovo Paradigma

La direttiva "use cache" è il cuore del nuovo sistema di caching in Next.js 16. Funziona in modo analogo a "use server" per le Server Actions: la piazzi in cima a un file, un componente o una funzione, e Next.js capisce che quel blocco di codice va memorizzato nella cache.

Abilitare il Nuovo Sistema

Per prima cosa, bisogna attivare il flag cacheComponents nel file di configurazione. Questo flag ha sostituito il precedente dynamicIO di Next.js 15:

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

const nextConfig: NextConfig = {
  experimental: {
    cacheComponents: true,
  },
};

export default nextConfig;

Cache a Livello di File (Intera Pagina)

Se volete che un'intera pagina sia cachata, basta mettere la direttiva in cima al file:

// app/blog/page.tsx
"use cache";

import { getBlogPosts } from '@/lib/blog';

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

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

È equivalente al vecchio comportamento delle pagine statiche, ma ora è esplicito: vedete chiaramente che la pagina è cachata. Niente più sorprese.

Cache a Livello di Componente

Potete applicare la cache a singoli componenti, lasciando il resto della pagina dinamico. Questo torna particolarmente utile in combinazione con lo Streaming (ne abbiamo parlato nel primo articolo della serie):

// components/FeaturedProducts.tsx
"use cache";

import { getFeaturedProducts } from '@/lib/products';

export async function FeaturedProducts() {
  const products = await getFeaturedProducts();

  return (
    <section>
      <h2>Prodotti in Evidenza</h2>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <div key={product.id}>
            <h3>{product.name}</h3>
            <p>€{product.price}</p>
          </div>
        ))}
      </div>
    </section>
  );
}

Cache a Livello di Funzione (Query al Database)

Il livello più granulare — e probabilmente il mio preferito. Potete cachare singole funzioni, il che è ideale per le query al database:

// lib/products.ts
import { db } from '@/lib/db';
import { cacheLife } from 'next/cache';

export async function getProductById(id: string) {
  "use cache";
  cacheLife("hours");

  const product = await db.product.findUnique({
    where: { id },
    include: { category: true, reviews: true },
  });

  return product;
}

Notate come la direttiva "use cache" dentro la funzione rende solo quel singolo punto di accesso ai dati cachabile, senza toccare il resto del modulo. Questo approccio granulare è uno dei veri vantaggi rispetto al vecchio sistema basato su fetch().

cacheLife: Controllare la Durata della Cache

Una cache senza controllo sulla durata è — diciamolo chiaramente — una bomba a orologeria. Next.js 16 introduce cacheLife per definire con precisione quanto a lungo i dati restano validi. La funzione va usata esclusivamente all'interno di blocchi "use cache".

Profili Predefiniti

Next.js fornisce profili pronti all'uso che coprono gli scenari più comuni:

  • "seconds" — stale: 0, revalidate: 1s, expire: 60s
  • "minutes" — stale: 5min, revalidate: 1min, expire: 1h
  • "hours" — stale: 5min, revalidate: 1h, expire: 1 giorno
  • "days" — stale: 5min, revalidate: 1 giorno, expire: 1 settimana
  • "weeks" — stale: 5min, revalidate: 1 settimana, expire: 30 giorni
  • "max" — stale: 5min, revalidate: mai automatico, expire: mai

Chiariamo i tre parametri. stale è il tempo durante il quale il dato viene servito direttamente dalla cache, senza nessun controllo. revalidate indica quando Next.js comincia a rigenerare il dato in background (il famoso stale-while-revalidate). expire è il limite massimo: superato quello, il dato viene proprio eliminato dalla cache.

Profili Inline Personalizzati

Quando i profili predefiniti non bastano (e capita più spesso di quanto si pensi), potete definire valori personalizzati direttamente nel codice:

// lib/pricing.ts
import { cacheLife } from 'next/cache';

export async function getProductPricing(productId: string) {
  "use cache";
  cacheLife({
    stale: 60,        // 1 minuto: servito dalla cache senza controlli
    revalidate: 300,   // 5 minuti: rigenera in background
    expire: 3600,      // 1 ora: eliminato dalla cache
  });

  const pricing = await fetchPricingAPI(productId);
  return pricing;
}

Profili Nominati nella Configurazione

Per mantenere coerenza nel progetto (soprattutto in team), è buona prassi definire profili personalizzati in next.config.ts:

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

const nextConfig: NextConfig = {
  experimental: {
    cacheComponents: true,
    cacheLife: {
      "catalogo-prodotti": {
        stale: 300,      // 5 minuti
        revalidate: 900, // 15 minuti
        expire: 86400,   // 1 giorno
      },
      "contenuto-editoriale": {
        stale: 600,        // 10 minuti
        revalidate: 3600,  // 1 ora
        expire: 604800,    // 1 settimana
      },
      "dati-utente": {
        stale: 0,
        revalidate: 30,
        expire: 300,
      },
    },
  },
};

export default nextConfig;

E poi li usate nel codice in modo semantico — molto più leggibile:

import { cacheLife } from 'next/cache';

export async function getCatalog() {
  "use cache";
  cacheLife("catalogo-prodotti");

  return await db.product.findMany({ where: { published: true } });
}

CacheLife Condizionale

Questo è un pattern avanzato che ho trovato utilissimo: adattare la durata della cache in base ai dati stessi.

import { cacheLife } from 'next/cache';

export async function getArticle(slug: string) {
  "use cache";

  const article = await db.article.findUnique({ where: { slug } });

  if (!article) {
    // Articolo non trovato: cache breve (potrebbe essere creato presto)
    cacheLife("seconds");
    return null;
  }

  if (article.status === 'draft') {
    // Bozza: non cachare a lungo, cambia spesso
    cacheLife("minutes");
  } else {
    // Pubblicato: cache più lunga
    cacheLife("contenuto-editoriale");
  }

  return article;
}

Un dettaglio importante: cacheLife può essere chiamata più volte nello stesso blocco, ma vince sempre il profilo con la durata più breve. Non è un bug, è una scelta intenzionale per garantire la coerenza dei dati.

cacheTag e Invalidazione On-Demand

Il caching basato sul tempo è solo metà della storia. Per contenuti che cambiano in modo imprevedibile — un prodotto aggiornato nel CMS, un commento moderato, un prezzo ritoccato all'ultimo minuto — serve l'invalidazione on-demand. Ed è qui che entrano in scena cacheTag, revalidateTag e il nuovo updateTag.

Taggare i Dati Cached

Con cacheTag assegnate delle etichette ai dati memorizzati, così da poterli invalidare chirurgicamente quando serve:

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

export async function getProduct(id: string) {
  "use cache";
  cacheLife("max");
  cacheTag(`product-${id}`, "products");

  return await db.product.findUnique({
    where: { id },
    include: { category: true },
  });
}

export async function getProductsByCategory(categoryId: string) {
  "use cache";
  cacheLife("max");
  cacheTag(`category-${categoryId}`, "products");

  return await db.product.findMany({
    where: { categoryId },
  });
}

Notate l'uso di tag multipli: ogni funzione ha un tag specifico (product-123) e uno generico (products). Questo vi dà la flessibilità di fare invalidazioni sia chirurgiche che massive, a seconda della situazione.

Invalidazione con revalidateTag

Quando usate cacheLife("max"), i dati non scadono mai da soli. L'unico modo per aggiornarli è l'invalidazione esplicita con revalidateTag:

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const payload = await request.json();
  const secret = request.headers.get('x-webhook-secret');

  if (secret !== process.env.WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Invalida il prodotto specifico
  revalidateTag(`product-${payload.productId}`);

  return NextResponse.json({ revalidated: true });
}

Il Nuovo updateTag per Server Actions

Questa è probabilmente la novità più interessante di Next.js 16. updateTag è pensato specificatamente per le Server Actions. A differenza di revalidateTag (che invalida in background), updateTag garantisce il pattern read-your-own-writes: l'utente che ha fatto la modifica vede subito i dati aggiornati, senza aspettare la rigenerazione.

Come abbiamo visto nell'articolo sulle Server Actions, questo pattern fa una differenza enorme nell'esperienza utente:

// app/actions/products.ts
"use server";

import { updateTag, revalidateTag } from 'next/cache';
import { db } from '@/lib/db';

export async function updateProduct(id: string, data: FormData) {
  const name = data.get('name') as string;
  const price = parseFloat(data.get('price') as string);

  await db.product.update({
    where: { id },
    data: { name, price },
  });

  // updateTag: l'utente corrente vede subito i dati aggiornati
  updateTag(`product-${id}`);

  // revalidateTag: gli altri utenti riceveranno i dati aggiornati
  // al prossimo refresh (stale-while-revalidate)
  revalidateTag(`category-${data.get('categoryId')}`);
}

Pattern CRUD Completo

Vediamo come mettere insieme tutti i pezzi in un pattern CRUD realistico:

// lib/posts.ts
import { cacheTag, cacheLife } from 'next/cache';

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

  return await db.post.findUnique({
    where: { slug },
    include: { author: true, comments: true },
  });
}

export async function getAllPosts() {
  "use cache";
  cacheLife("max");
  cacheTag("posts");

  return await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  });
}
// app/actions/posts.ts
"use server";

import { updateTag, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function createPost(data: FormData) {
  const post = await db.post.create({
    data: {
      title: data.get('title') as string,
      slug: data.get('slug') as string,
      content: data.get('content') as string,
      published: true,
    },
  });

  // Invalida la lista: il nuovo post deve apparire
  revalidateTag("posts");
  redirect(`/blog/${post.slug}`);
}

export async function editPost(slug: string, data: FormData) {
  await db.post.update({
    where: { slug },
    data: {
      title: data.get('title') as string,
      content: data.get('content') as string,
    },
  });

  // L'utente vede subito la modifica
  updateTag(`post-${slug}`);
  // Gli altri vedranno l'aggiornamento gradualmente
  revalidateTag("posts");
}

export async function deletePost(slug: string) {
  await db.post.delete({ where: { slug } });

  revalidateTag(`post-${slug}`);
  revalidateTag("posts");
  redirect('/blog');
}

ISR nel 2026: Revalidazione Basata sul Tempo

L'Incremental Static Regeneration (ISR) resta uno dei pattern più potenti di Next.js, anche nell'era di "use cache". Se il vostro contenuto cambia con una cadenza prevedibile — articoli di blog, pagine prodotto, landing page — ISR è spesso la scelta più semplice e diretta. A volte la soluzione migliore è anche quella meno sofisticata.

ISR con App Router

Nell'App Router, ISR si configura a livello di rotta con revalidate:

// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts';
import { notFound } from 'next/navigation';

// Rigenera la pagina ogni 60 secondi
export const revalidate = 60;

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// Genera le pagine più popolari al build time
export async function generateStaticParams() {
  const posts = await db.post.findMany({
    select: { slug: true },
    take: 100,
    orderBy: { views: 'desc' },
  });

  return posts.map((post) => ({ slug: post.slug }));
}

ISR a Livello di Fetch

Potete anche controllare la revalidazione a livello di singola fetch(), utile quando diverse parti della stessa pagina hanno tempistiche diverse:

// app/shop/page.tsx
export default async function ShopPage() {
  // Prezzi: aggiornati ogni 5 minuti
  const prices = await fetch('https://api.example.com/prices', {
    next: { revalidate: 300 },
  });

  // Catalogo: aggiornato ogni ora
  const catalog = await fetch('https://api.example.com/catalog', {
    next: { revalidate: 3600 },
  });

  // Inventario: sempre fresco
  const inventory = await fetch('https://api.example.com/inventory', {
    cache: 'no-store',
  });

  // ...render
}

Attenzione, regola fondamentale: quando nella stessa rotta avete più fetch con tempi di revalidazione diversi, la rotta adotta il tempo più breve. Nell'esempio sopra, quel cache: 'no-store' sull'inventario rende l'intera pagina dinamica. Se volete che solo l'inventario sia dinamico, spostatelo in un componente separato con Suspense (come abbiamo visto nell'articolo sullo Streaming).

Resilienza agli Errori

Un vantaggio di ISR che molti sottovalutano è la resilienza. Se la rigenerazione fallisce (API giù, timeout del database), Next.js continua a servire la versione precedente dalla cache. I vostri utenti vedranno dati leggermente datati piuttosto che una bella pagina di errore. In un e-commerce, questo può fare la differenza tra una vendita completata e un cliente perso.

use cache: private e use cache: remote

La direttiva base "use cache" memorizza i dati in una cache locale condivisa tra tutti gli utenti. Ma Next.js 16 introduce due varianti per scenari più specifici.

Cache Privata per Dati Utente

"use cache: private" è pensata per dati specifici dell'utente. A differenza della cache standard, questa variante ha accesso ai cookies e agli header della richiesta, il che permette di cachare contenuti personalizzati senza rischi:

// components/UserDashboard.tsx
"use cache: private";

import { cookies } from 'next/headers';
import { cacheLife } from 'next/cache';
import { getUserData } from '@/lib/user';

export async function UserDashboard() {
  cacheLife("dati-utente");

  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session')?.value;

  if (!sessionId) return null;

  const userData = await getUserData(sessionId);

  return (
    <div>
      <h2>Benvenuto, {userData.name}</h2>
      <p>Ordini recenti: {userData.recentOrders.length}</p>
      <p>Punti fedeltà: {userData.loyaltyPoints}</p>
    </div>
  );
}

La cache privata è isolata per utente: i dati di un utente non finiranno mai serviti a un altro. Questo la rende sicura per dashboard, profili, preferenze e qualsiasi informazione sensibile.

Cache Remota per Ambienti Serverless

"use cache: remote" risolve un problema molto concreto degli ambienti serverless. Ogni istanza della funzione ha la propria cache locale, e quando l'istanza viene riciclata la cache va persa. La cache remota condivide i dati tra tutte le istanze, appoggiandosi a un backend esterno (Redis, Memcached, o il servizio cache del provider):

// lib/shared-data.ts
import { cacheLife, cacheTag } from 'next/cache';

export async function getGlobalConfig() {
  "use cache: remote";
  cacheLife("hours");
  cacheTag("global-config");

  // Questa query viene eseguita una sola volta,
  // il risultato è condiviso tra tutte le istanze serverless
  return await db.config.findFirst({
    where: { active: true },
  });
}

Pattern Multi-Livello in Produzione

In un'applicazione reale, finirete per combinare le tre varianti a seconda della natura dei dati. Ecco come potrebbe apparire una pagina prodotto:

// app/shop/[productId]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { UserRecommendations } from '@/components/UserRecommendations';
import { SiteConfig } from '@/components/SiteConfig';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ productId: string }>;
}) {
  const { productId } = await params;

  return (
    <main>
      {/* Cache remota: condivisa tra tutte le istanze */}
      <SiteConfig />

      {/* Cache standard: dati prodotto condivisi tra utenti */}
      <ProductDetails productId={productId} />

      {/* Cache privata: raccomandazioni personalizzate */}
      <Suspense fallback={<p>Caricamento raccomandazioni...</p>}>
        <UserRecommendations productId={productId} />
      </Suspense>
    </main>
  );
}
// components/SiteConfig.tsx
"use cache: remote";
import { cacheLife } from 'next/cache';

export async function SiteConfig() {
  cacheLife("days");
  const config = await db.siteConfig.findFirst();
  return <header>{/* banner, annunci, config globale */}</header>;
}

// components/ProductDetails.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';

export async function ProductDetails({ productId }: { productId: string }) {
  cacheLife("catalogo-prodotti");
  cacheTag(`product-${productId}`);
  const product = await db.product.findUnique({ where: { id: productId } });
  return <section>{/* dettagli prodotto */}</section>;
}

// components/UserRecommendations.tsx
"use cache: private";
import { cookies } from 'next/headers';
import { cacheLife } from 'next/cache';

export async function UserRecommendations({ productId }: { productId: string }) {
  cacheLife("minutes");
  const cookieStore = await cookies();
  const userId = cookieStore.get('userId')?.value;
  const recs = await getRecommendations(userId, productId);
  return <aside>{/* raccomandazioni personalizzate */}</aside>;
}

Pattern di Produzione: Strategie per Tipo di Contenuto

Allora, come scegliere la strategia giusta? Dipende dalla natura dei dati. Ho messo insieme una tabella riassuntiva che tengo sempre a portata di mano:

┌─────────────────────────┬──────────────────────────┬────────────────────┐
│ Tipo di Contenuto       │ Strategia                │ Durata             │
├─────────────────────────┼──────────────────────────┼────────────────────┤
│ Pagine marketing/legal  │ "use cache" + "max"      │ Invalida on-demand │
│ Articoli blog           │ ISR revalidate: 3600     │ 1 ora              │
│ Catalogo prodotti       │ "use cache" + cacheTag   │ Invalida on-demand │
│ Prezzi / disponibilità  │ "use cache" + "minutes"  │ 5 minuti           │
│ Carrello / sessione     │ "use cache: private"     │ 30 secondi         │
│ Dashboard utente        │ "use cache: private"     │ 1 minuto           │
│ Risultati di ricerca    │ Nessuna cache             │ Sempre dinamico    │
│ Config globale sito     │ "use cache: remote"      │ 1 giorno           │
│ Feed social / commenti  │ ISR revalidate: 30       │ 30 secondi         │
└─────────────────────────┴──────────────────────────┴────────────────────┘

Esempio Reale: E-Commerce con Cache Ibrida

Vediamo un caso concreto: come un e-commerce può combinare ISR, cacheTag e "use cache" per la pagina di una categoria.

// app/categories/[slug]/page.tsx
import { Suspense } from 'react';
import { cacheTag, cacheLife } from 'next/cache';
import { ProductGrid } from '@/components/ProductGrid';
import { CategoryHeader } from '@/components/CategoryHeader';

// ISR: rigenera ogni 15 minuti come baseline
export const revalidate = 900;

export default async function CategoryPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <main>
      <CategoryHeader slug={slug} />
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid slug={slug} />
      </Suspense>
    </main>
  );
}
// components/ProductGrid.tsx
import { cacheTag, cacheLife } from 'next/cache';
import { LiveInventoryBadge } from './LiveInventoryBadge';

async function getCategoryProducts(slug: string) {
  "use cache";
  cacheLife("max");
  cacheTag(`category-${slug}`, "products");

  return await db.product.findMany({
    where: { category: { slug } },
    include: { images: true },
    orderBy: { sortOrder: 'asc' },
  });
}

export async function ProductGrid({ slug }: { slug: string }) {
  const products = await getCategoryProducts(slug);

  return (
    <div className="grid grid-cols-4 gap-6">
      {products.map((product) => (
        <div key={product.id}>
          <img src={product.images[0]?.url} alt={product.name} />
          <h3>{product.name}</h3>
          <p>€{product.price}</p>
          <Suspense fallback={<span>...</span>}>
            <LiveInventoryBadge productId={product.id} />
          </Suspense>
        </div>
      ))}
    </div>
  );
}

La logica qui è chiara: il catalogo è cachato con cacheLife("max") e invalidato on-demand tramite webhook dal CMS. ISR a livello di rotta fa da rete di sicurezza — anche se il webhook dovesse fallire, la pagina si aggiorna comunque entro 15 minuti. La disponibilità dell'inventario, essendo un dato critico e volatile, resta dinamica dentro un Suspense.

Debug della Cache in Produzione

Se qualcosa non torna e avete bisogno di capire cosa sta succedendo nella cache, c'è una variabile d'ambiente che vi salverà la giornata:

NEXT_PRIVATE_DEBUG_CACHE=1 next start

Vedrete nei log ogni HIT, MISS e STALE della cache, con i tag e le durate associate. Fidatevi, quando avete un bug di cache in produzione alle 11 di sera, questo strumento diventa il vostro migliore amico.

Errori Comuni e Come Evitarli

Ok, adesso che sappiamo come funziona il caching, parliamo delle trappole. Perché ce ne sono, e ci cadono anche gli sviluppatori con più esperienza.

1. Confondere il Comportamento in Dev e Produzione

In modalità sviluppo (next dev), la cache è disabilitata di default per facilitarvi la vita. Comodo, certo. Ma significa anche che potreste non accorgervi di problemi di cache fino al deploy. Il consiglio è semplice: testate sempre con next build && next start prima di mandare qualsiasi cosa in produzione.

2. Mescolare cache: "no-store" con revalidate

Queste due opzioni sono mutuamente esclusive sulla stessa fetch. Sembra ovvio, eppure è un errore che si vede di continuo:

// ERRORE: non ha senso
const data = await fetch(url, {
  cache: 'no-store',
  next: { revalidate: 60 }, // ignorato!
});

// CORRETTO: scegliete uno dei due
const freshData = await fetch(url, { cache: 'no-store' });
const cachedData = await fetch(url, { next: { revalidate: 60 } });

3. Dimenticare la Router Cache

Questo è probabilmente l'errore più frustrante di tutti. Avete invalidato la cache sul server, tutto perfetto. Ma il client continua a mostrare i vecchi dati. La colpa? La Router Cache client-side. Dopo una Server Action che modifica i dati, dovete usare router.refresh() nel componente client, oppure affidarvi a updateTag che gestisce la cosa automaticamente:

// components/EditProductForm.tsx
"use client";

import { useRouter } from 'next/navigation';
import { updateProduct } from '@/app/actions/products';

export function EditProductForm({ product }: { product: Product }) {
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    await updateProduct(product.id, formData);
    // Forza il refresh della Router Cache client-side
    router.refresh();
  }

  return (
    <form action={handleSubmit}>
      {/* campi del form */}
    </form>
  );
}

4. Più Fetch con Tempi Diversi nella Stessa Rotta

L'abbiamo già accennato nella sezione ISR, ma vale la pena ribadirlo: se mescolate fetch con tempi diversi nella stessa rotta, il tempo più breve (o no-store) vince per l'intera rotta. La soluzione è sempre la stessa — isolate le fetch con tempi diversi in componenti separati, avvolti in Suspense.

5. Migrare da unstable_cache a use cache

Se state arrivando da Next.js 14 o 15, probabilmente avete unstable_cache sparso un po' ovunque nel codice. La buona notizia è che la migrazione a "use cache" è abbastanza diretta:

// PRIMA (Next.js 14/15)
import { unstable_cache } from 'next/cache';

const getCachedUser = unstable_cache(
  async (id: string) => db.user.findUnique({ where: { id } }),
  ['user'],
  { revalidate: 3600, tags: ['users'] }
);

// DOPO (Next.js 16)
import { cacheLife, cacheTag } from 'next/cache';

async function getUser(id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(`user-${id}`, "users");

  return await db.user.findUnique({ where: { id } });
}

La nuova API è più leggibile, non richiede wrapper, e funziona in modo naturale con TypeScript senza cast aggiuntivi. Un netto miglioramento, se me lo chiedete.

Domande Frequenti (FAQ)

Qual è la differenza tra revalidateTag e updateTag in Next.js 16?

revalidateTag invalida la cache in background: la prossima richiesta riceve ancora i dati vecchi (stale) mentre Next.js rigenera quelli nuovi in sottofondo. Va benissimo per webhook, cron job e invalidazioni che non richiedono feedback immediato. updateTag, invece, è pensato per le Server Actions e garantisce il pattern read-your-own-writes: chi ha fatto la modifica vede subito i dati aggiornati, senza passare per la fase stale. In pratica? Usate updateTag nelle Server Actions e revalidateTag per tutto il resto.

Come funziona il caching in Next.js 16 rispetto a Next.js 14?

La differenza fondamentale è filosofica. In Next.js 14 il caching era implicito e opt-out: ogni fetch() veniva cachata automaticamente, e dovevate usare cache: "no-store" per evitarlo. In Next.js 16, il caching è esplicito e opt-in: nulla viene cachato a meno che non usiate "use cache", revalidate a livello di rotta, o next: { revalidate } nella fetch. Oltre a questo, Next.js 16 porta cacheLife per il controllo granulare della durata, cacheTag/updateTag per l'invalidazione precisa, e le varianti "use cache: private" e "use cache: remote" per casi d'uso avanzati.

Devo usare ISR o use cache per il mio progetto?

Dipende. Se i contenuti cambiano con una cadenza prevedibile e regolare (blog aggiornato ogni ora, prezzi che cambiano ogni 5 minuti), ISR con revalidate è la strada più semplice. Se invece i contenuti cambiano in modo imprevedibile e volete invalidazione chirurgica (un prodotto aggiornato dal CMS, un commento moderato), "use cache" con cacheLife("max") e cacheTag è la scelta giusta. Nella pratica, spesso finirete per usare entrambi: ISR come baseline temporale e cacheTag per l'invalidazione immediata quando serve davvero.

Perché i dati non si aggiornano dopo la revalidazione in Next.js?

Le cause più comuni sono tre. Primo: la Router Cache client-side. Anche se il server ha rigenerato la pagina, il browser potrebbe ancora mostrare la versione cachata localmente. Soluzione: router.refresh() dopo le mutazioni, o meglio ancora updateTag nelle Server Actions. Secondo: state testando in modalità sviluppo dove la cache si comporta diversamente dalla produzione — testate con next build && next start. Terzo: state usando revalidateTag che funziona in modalità stale-while-revalidate: la prima richiesta dopo l'invalidazione riceve ancora i dati vecchi, è solo la seconda che porta quelli nuovi.

Come si fa il debug della cache in Next.js in produzione?

Lo strumento principale è la variabile d'ambiente NEXT_PRIVATE_DEBUG_CACHE=1, da impostare all'avvio con NEXT_PRIVATE_DEBUG_CACHE=1 next start. Vi mostrerà nei log ogni operazione: HIT, MISS, STALE, REVALIDATED, completi di tag e durate. Per un monitoraggio più strutturato, potete creare un custom cache handler che logga su un servizio di osservabilità come Datadog o Grafana. Infine, date sempre un'occhiata agli header HTTP della risposta: x-nextjs-cache vi dice lo stato della cache per le rotte statiche, con valori come HIT, MISS o STALE.

Sull'Autore Editorial Team

Our team of expert writers and editors.