Streaming e Partial Prerendering in Next.js: Guida Completa all'Architettura Server-First

Scopri come implementare Streaming SSR e Partial Prerendering (PPR) in Next.js per prestazioni web ottimali. Guida pratica con esempi su React Suspense, loading.js, Cache Components e pattern avanzati per migliorare TTFB, LCP e Core Web Vitals.

Introduzione: Perché lo Streaming SSR Cambia le Regole del Gioco

Vi è mai capitato di aspettare davanti a uno schermo bianco, chiedendovi se la pagina si fosse rotta? Per anni, il Server-Side Rendering in Next.js ha funzionato esattamente così: un modello sequenziale e bloccante. Il server riceveva la richiesta, recuperava tutti i dati, generava l'intero HTML e solo allora inviava la risposta. Se una singola query al database impiegava 2 secondi, l'utente restava lì a fissare il nulla per 2 secondi — anche se il 90% della pagina era pronto in 50 millisecondi.

Frustrante, vero?

Lo streaming SSR ribalta completamente questo paradigma. Invece di aspettare che tutto sia pronto, il server inizia a inviare chunk di HTML al browser non appena sono disponibili. Layout, navigazione e contenuto statico arrivano immediatamente, mentre le sezioni che dipendono da dati asincroni vengono inviate progressivamente man mano che i risultati sono pronti.

E non stiamo parlando di un'ottimizzazione marginale. In scenari reali, lo streaming ha dimostrato di poter ridurre il Time to First Byte (TTFB) da 450ms a 45ms e il Largest Contentful Paint (LCP) da 1.2s a 380ms, utilizzando Suspense boundaries scaglionati che si risolvono rispettivamente a T=80ms, T=150ms e T=300ms. Siamo nell'ordine di grandezza di miglioramento, non in un aggiustamento percentuale.

Ma lo streaming è solo metà della storia. Con il Partial Prerendering (PPR), Next.js va ancora oltre: combina il rendering statico al momento del build con quello dinamico al momento della richiesta, tutto nella stessa route. Il risultato? Una shell statica servita istantaneamente dalla CDN, con "buchi" dinamici che vengono riempiti tramite streaming.

In questa guida esploreremo in profondità entrambe le tecnologie: come funzionano, come implementarle, come si confrontano tra loro e con gli approcci tradizionali, e quali pattern avanzati adottare per ottenere prestazioni web davvero ottimali.

Come Funziona lo Streaming nell'App Router di Next.js

L'App Router di Next.js, introdotto nella versione 13 e stabilizzato nella 14, è costruito interamente su React Server Components e React Suspense. Tutti i componenti nell'App Router sono Server Component per default: vengono eseguiti sul server, non inviano JavaScript al browser e possono accedere direttamente a database, file system e API interne.

Lo streaming sfrutta una capacità fondamentale di HTTP (spesso sottovalutata): la possibilità di inviare una risposta in chunk successivi prima che la risposta completa sia pronta. Quando il server inizia a renderizzare una pagina, incontra dei Suspense boundary — punti nel componente tree dove React sa che dovrà attendere dati asincroni. Invece di bloccarsi, il server:

  1. Renderizza immediatamente tutto ciò che può (layout, navigazione, contenuto statico, fallback dei Suspense boundary)
  2. Invia questo HTML iniziale al browser come primo chunk
  3. Continua a renderizzare i componenti asincroni in parallelo
  4. Man mano che ogni componente asincrono si risolve, invia un nuovo chunk di HTML con il contenuto reale che sostituisce il fallback

Lato browser, React gestisce la sostituzione dei fallback con il contenuto reale senza ricaricare la pagina. Questo avviene tramite il React Server Component Payload (RSC Payload), un formato binario compatto che rappresenta l'albero dei componenti renderizzati sul server. L'idratazione dei Client Component avviene in parallelo con lo streaming — il che significa che le parti interattive della pagina diventano funzionali il prima possibile.

Il Ruolo di React Suspense

React Suspense è il meccanismo fondamentale che rende possibile tutto questo. Un Suspense boundary definisce un confine tra contenuto immediatamente disponibile e contenuto che richiede attesa. Quando un Server Component all'interno di un Suspense boundary esegue un'operazione asincrona (come un fetch di dati), React mostra il fallback specificato mentre attende la risoluzione.

// Esempio concettuale di Suspense con streaming
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      {/* Questo contenuto viene inviato immediatamente */}
      <p>Benvenuto nella tua dashboard.</p>

      {/* Questo mostra il fallback, poi il contenuto reale quando i dati arrivano */}
      <Suspense fallback={<p>Caricamento statistiche...</p>}>
        <AsyncStatistics />
      </Suspense>
    </main>
  )
}

async function AsyncStatistics() {
  const stats = await fetchStats() // operazione asincrona
  return (
    <div>
      <p>Utenti attivi: {stats.activeUsers}</p>
      <p>Vendite oggi: {stats.salesToday}</p>
    </div>
  )
}

Un punto che vale la pena sottolineare: la navigazione è interrompibile. L'utente non deve aspettare che l'intera pagina sia caricata prima di poter navigare altrove. Può cliccare su un link e il framework gestirà la transizione immediatamente, interrompendo lo streaming della pagina precedente. Onestamente, questo dettaglio da solo migliora l'esperienza utente in modo significativo.

Due Approcci: Streaming Automatico e Streaming Manuale

Next.js offre due modalità per implementare lo streaming, ciascuna adatta a scenari diversi. Capire quando usare l'una o l'altra fa tutta la differenza.

Approccio 1: Streaming Automatico con loading.js

Il file loading.js (o loading.tsx) è una convenzione dell'App Router che crea automaticamente un Suspense boundary attorno al contenuto della pagina. Quando presente nella stessa directory di un page.js, Next.js lo utilizza come fallback mentre il contenuto della pagina viene caricato.

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 bg-gray-200 rounded w-1/3"></div>
      <div className="grid grid-cols-3 gap-4">
        <div className="h-32 bg-gray-200 rounded"></div>
        <div className="h-32 bg-gray-200 rounded"></div>
        <div className="h-32 bg-gray-200 rounded"></div>
      </div>
      <div className="h-64 bg-gray-200 rounded"></div>
    </div>
  )
}
// app/dashboard/page.tsx
import { fetchDashboardData } from '@/lib/data'

export default async function DashboardPage() {
  const data = await fetchDashboardData()

  return (
    <div>
      <h1>{data.title}</h1>
      <div className="grid grid-cols-3 gap-4">
        {data.cards.map(card => (
          <DashboardCard key={card.id} {...card} />
        ))}
      </div>
      <RecentActivity items={data.recentActivity} />
    </div>
  )
}

L'architettura interna è piuttosto elegante: loading.js è un Server Component per default, annidato all'interno di layout.js. Next.js genera automaticamente una struttura equivalente a questa:

// Struttura generata internamente da Next.js
<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

In pratica il layout rimane sempre visibile e interattivo, mentre solo il contenuto della pagina mostra lo stato di caricamento. L'utente può interagire con la barra laterale, la navigazione e qualsiasi altro elemento del layout senza dover aspettare.

Approccio 2: Streaming Manuale con Suspense

L'approccio con loading.js funziona a livello di segmento di route (layout e pagine), ma per uno streaming più granulare serve <Suspense> direttamente nei componenti. Questo vi permette di controllare esattamente quali parti della pagina vengono mostrate subito e quali vengono caricate progressivamente.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from '@/components/RevenueChart'
import { LatestInvoices } from '@/components/LatestInvoices'
import { CardsSkeleton, RevenueChartSkeleton, InvoicesSkeleton } from '@/components/Skeletons'
import { StatsCards } from '@/components/StatsCards'

export default function DashboardPage() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>

      {/* Le card delle statistiche si caricano per prime */}
      <Suspense fallback={<CardsSkeleton />}>
        <StatsCards />
      </Suspense>

      <div className="grid grid-cols-2 gap-6 mt-6">
        {/* Il grafico e le fatture si caricano indipendentemente */}
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<InvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  )
}
// components/StatsCards.tsx - Server Component asincrono
import { fetchCardData } from '@/lib/data'

export async function StatsCards() {
  const { totalRevenue, totalInvoices, paidInvoices, pendingInvoices } = await fetchCardData()

  return (
    <div className="grid grid-cols-4 gap-4">
      <Card title="Ricavi Totali" value={totalRevenue} />
      <Card title="Fatture Totali" value={totalInvoices} />
      <Card title="Fatture Pagate" value={paidInvoices} />
      <Card title="Fatture in Sospeso" value={pendingInvoices} />
    </div>
  )
}

In questo esempio, ogni sezione della dashboard si carica indipendentemente. Se il grafico dei ricavi impiega 300ms ma le card delle statistiche sono pronte in 80ms, l'utente vede le card quasi immediatamente mentre il grafico mostra ancora lo skeleton loader. È una differenza che si sente davvero nell'uso quotidiano.

Esempi Pratici: Dashboard, Layout con Sidebar e Feed di Contenuti

Dashboard con Card Indipendenti

Uno degli scenari più comuni per lo streaming è una dashboard dove diverse sezioni recuperano dati da fonti diverse, ciascuna con tempi di risposta propri. Vediamo come strutturarla.

// app/admin/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from '@/components/admin/UserStats'
import { SalesOverview } from '@/components/admin/SalesOverview'
import { RecentOrders } from '@/components/admin/RecentOrders'
import { SystemHealth } from '@/components/admin/SystemHealth'

export default function AdminDashboard() {
  return (
    <div className="space-y-6">
      <h1 className="text-3xl font-bold">Pannello Amministratore</h1>

      {/* Prima riga: statistiche rapide (fonte: database interno, ~50ms) */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>

      {/* Seconda riga: panoramica vendite + ordini recenti */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <SalesOverview />  {/* fonte: API analytics, ~200ms */}
        </Suspense>

        <Suspense fallback={<TableSkeleton rows={5} />}>
          <RecentOrders />  {/* fonte: database ordini, ~150ms */}
        </Suspense>
      </div>

      {/* Terza riga: stato del sistema (fonte: API monitoring, ~300ms) */}
      <Suspense fallback={<HealthSkeleton />}>
        <SystemHealth />
      </Suspense>
    </div>
  )
}

function StatsSkeleton() {
  return (
    <div className="grid grid-cols-4 gap-4">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="h-24 bg-gray-100 animate-pulse rounded-lg" />
      ))}
    </div>
  )
}

function ChartSkeleton() {
  return <div className="h-80 bg-gray-100 animate-pulse rounded-lg" />
}

function TableSkeleton({ rows }: { rows: number }) {
  return (
    <div className="space-y-3">
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="h-12 bg-gray-100 animate-pulse rounded" />
      ))}
    </div>
  )
}

function HealthSkeleton() {
  return <div className="h-40 bg-gray-100 animate-pulse rounded-lg" />
}

Layout con Sidebar e Contenuto Principale

Un altro pattern molto frequente: sidebar con dati di navigazione personalizzati (notifiche, avatar utente) e un contenuto principale che cambia pagina per pagina. È il classico layout da app SaaS, per intenderci.

// app/(app)/layout.tsx
import { Suspense } from 'react'
import { StaticNav } from '@/components/StaticNav'
import { UserPanel } from '@/components/UserPanel'

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      {/* Sidebar: navigazione statica immediata + pannello utente dinamico */}
      <aside className="w-64 border-r bg-gray-50 p-4">
        {/* I link di navigazione sono statici, appaiono subito */}
        <StaticNav />

        {/* Il pannello utente richiede autenticazione e dati dal DB */}
        <Suspense fallback={<UserPanelSkeleton />}>
          <UserPanel />
        </Suspense>
      </aside>

      {/* Contenuto principale: gestito dalle pagine figlie */}
      <main className="flex-1 p-6">
        {children}
      </main>
    </div>
  )
}

function UserPanelSkeleton() {
  return (
    <div className="mt-auto space-y-3">
      <div className="h-10 w-10 bg-gray-200 rounded-full animate-pulse" />
      <div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse" />
      <div className="h-3 bg-gray-200 rounded w-1/2 animate-pulse" />
    </div>
  )
}
// components/UserPanel.tsx - Server Component
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function UserPanel() {
  const session = await auth()
  if (!session?.user) return null

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { name: true, email: true, avatar: true, notificationCount: true },
  })

  return (
    <div className="mt-auto pt-4 border-t">
      <div className="flex items-center gap-3">
        <img src={user?.avatar} alt="Avatar utente" className="h-10 w-10 rounded-full" />
        <div>
          <p className="font-medium text-sm">{user?.name}</p>
          <p className="text-xs text-gray-500">{user?.email}</p>
        </div>
      </div>
      {user?.notificationCount > 0 && (
        <div className="mt-2 text-xs text-blue-600">
          {user.notificationCount} notifiche non lette
        </div>
      )}
    </div>
  )
}

Feed di Contenuti con Caricamento Progressivo

// app/feed/page.tsx
import { Suspense } from 'react'
import { FeaturedPosts } from '@/components/FeaturedPosts'
import { RecentPosts } from '@/components/RecentPosts'
import { TrendingTopics } from '@/components/TrendingTopics'

export default function FeedPage() {
  return (
    <div className="max-w-4xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Il tuo Feed</h1>

      {/* Contenuti in evidenza: alta priorità, devono caricare per primi */}
      <Suspense fallback={<FeaturedSkeleton />}>
        <FeaturedPosts />
      </Suspense>

      <div className="grid grid-cols-3 gap-6 mt-8">
        {/* Colonna principale: post recenti */}
        <div className="col-span-2">
          <Suspense fallback={<PostListSkeleton count={10} />}>
            <RecentPosts />
          </Suspense>
        </div>

        {/* Sidebar: argomenti di tendenza */}
        <aside>
          <Suspense fallback={<TopicsSkeleton />}>
            <TrendingTopics />
          </Suspense>
        </aside>
      </div>
    </div>
  )
}

function FeaturedSkeleton() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <div className="h-48 bg-gray-100 animate-pulse rounded-xl" />
      <div className="h-48 bg-gray-100 animate-pulse rounded-xl" />
    </div>
  )
}

function PostListSkeleton({ count }: { count: number }) {
  return (
    <div className="space-y-4">
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="h-20 bg-gray-100 animate-pulse rounded-lg" />
      ))}
    </div>
  )
}

function TopicsSkeleton() {
  return (
    <div className="space-y-3">
      <div className="h-6 bg-gray-100 rounded w-3/4 animate-pulse" />
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="h-4 bg-gray-100 rounded animate-pulse" />
      ))}
    </div>
  )
}

Partial Prerendering (PPR): L'Architettura "Static Shell + Dynamic Holes"

Ora arriviamo alla parte che mi entusiasma di più. Il Partial Prerendering rappresenta l'evoluzione naturale dello streaming e, a partire da Next.js 16 con i Cache Components, completa la visione di un rendering ibrido dentro una singola route. Prima di PPR, Next.js doveva fare una scelta netta: o una route era completamente statica (SSG) o completamente dinamica (SSR). Non c'era via di mezzo.

PPR elimina questa dicotomia. L'idea fondamentale è semplice ma potente:

  1. Al momento del build, Next.js pre-renderizza una "shell statica" della pagina: layout, navigazione, header, footer, e tutti i fallback dei Suspense boundary. Questa shell è un file HTML statico.
  2. Al momento della richiesta, il server invia immediatamente la shell statica (che può essere servita dalla CDN edge più vicina), poi inizia a renderizzare le parti dinamiche — i "buchi" lasciati dai Suspense boundary — e le invia tramite streaming nello stesso flusso HTTP.

Il risultato è un singolo stream HTTP che contiene sia il contenuto statico che quello dinamico. L'utente riceve la shell in pochi millisecondi (come con un sito completamente statico), poi vede i contenuti dinamici apparire progressivamente (come con lo streaming SSR). Il meglio dei due mondi, davvero.

Come i Suspense Boundary Definiscono i "Buchi" Dinamici

In PPR, i Suspense boundary hanno un doppio ruolo: il loro fallback viene pre-renderizzato al momento del build come parte della shell statica, mentre il contenuto figlio viene renderizzato al momento della richiesta. Questo significa che i vostri skeleton loader non vengono mai generati a runtime — fanno già parte dell'HTML statico.

Un dettaglio che spesso passa inosservato, ma che ha un impatto enorme sulle performance percepite.

// Con PPR abilitato, questa pagina viene parzialmente pre-renderizzata
// app/prodotti/[id]/page.tsx

import { Suspense } from 'react'
import { ProductDetails } from '@/components/ProductDetails'
import { ProductReviews } from '@/components/ProductReviews'
import { RelatedProducts } from '@/components/RelatedProducts'
import { AddToCartButton } from '@/components/AddToCartButton'

export default async function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div className="max-w-6xl mx-auto">
      {/* Pre-renderizzato nella shell statica (usa 'use cache') */}
      <Suspense fallback={<ProductDetailsSkeleton />}>
        <ProductDetails id={params.id} />
      </Suspense>

      {/* Dinamico: dipende dalla sessione utente (cookies) */}
      <Suspense fallback={<ButtonSkeleton />}>
        <AddToCartButton productId={params.id} />
      </Suspense>

      {/* Dinamico: recensioni aggiornate in tempo reale */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>

      {/* Pre-renderizzato nella shell statica (usa 'use cache') */}
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts categoryId={params.id} />
      </Suspense>
    </div>
  )
}

In questo esempio, i dettagli del prodotto e i prodotti correlati possono essere cachati con la direttiva "use cache", diventando parte della shell statica. Il pulsante "Aggiungi al carrello" e le recensioni, che dipendono rispettivamente dalla sessione utente e da dati in tempo reale, rimangono dinamici e vengono riempiti tramite streaming.

Come Abilitare e Configurare PPR in Next.js

Configurazione in Next.js 15 (Sperimentale)

In Next.js 15, PPR è ancora una funzionalità sperimentale. Si abilita nel file di configurazione e poi si attiva route per route:

// next.config.ts (Next.js 15)
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}

export default nextConfig
// app/prodotti/[id]/page.tsx (Next.js 15)
// Opt-in esplicito per questa route
export const experimental_ppr = true

export default async function ProductPage({ params }) {
  // ... componente della pagina
}

Configurazione in Next.js 16 (Cache Components)

Con Next.js 16, il flag experimental.ppr è stato rimosso in favore dei Cache Components. La nuova configurazione è decisamente più pulita:

// next.config.ts (Next.js 16)
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Con cacheComponents: true, il rendering è dinamico per default. Per rendere statica (cachabile) una parte della pagina, si usa la direttiva "use cache":

// components/ProductDetails.tsx (Next.js 16 con Cache Components)
import { cacheLife } from 'next/cache'

export async function ProductDetails({ id }: { id: string }) {
  'use cache'
  cacheLife('hours')

  const product = await db.product.findUnique({ where: { id } })

  return (
    <div>
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-gray-600 mt-2">{product.description}</p>
      <p className="text-2xl font-bold mt-4">€{product.price}</p>
    </div>
  )
}
// components/AddToCartButton.tsx - Componente dinamico (nessun 'use cache')
import { cookies } from 'next/headers'

export async function AddToCartButton({ productId }: { productId: string }) {
  const sessionId = cookies().get('session')?.value
  const cartCount = await getCartCount(sessionId)

  return (
    <div className="flex items-center gap-4 mt-4">
      <button className="bg-blue-600 text-white px-6 py-3 rounded-lg">
        Aggiungi al Carrello
      </button>
      <span className="text-sm text-gray-500">
        {cartCount} articoli nel carrello
      </span>
    </div>
  )
}

La direttiva "use cache" può essere applicata a tre livelli: pagina intera, singolo componente, o singola funzione. La funzione cacheLife controlla per quanto tempo il contenuto resta in cache, accettando profili predefiniti come 'hours', 'days', 'weeks' o configurazioni personalizzate.

Rivalidazione dei Dati Cachati

// lib/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: FormData) {
  await db.product.update({ where: { id }, data: { /* ... */ } })

  // Rivalidare il tag specifico per aggiornare la shell statica
  revalidateTag(`product-${id}`)
}

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

export async function ProductDetails({ id }: { id: string }) {
  'use cache'
  cacheLife('days')
  cacheTag(`product-${id}`)

  const product = await db.product.findUnique({ where: { id } })
  // ...
}

PPR vs Streaming vs SSR vs SSG: Confronto Dettagliato

Per scegliere la strategia di rendering più adatta, è essenziale capire le differenze tra i quattro approcci principali. Ho messo insieme questa tabella comparativa che trovo particolarmente utile come riferimento rapido.

Caratteristica SSG (Static) SSR Tradizionale Streaming SSR PPR
Quando viene generato l'HTML Build time Request time (tutto) Request time (progressivo) Build time (shell) + Request time (buchi)
TTFB Eccellente (~10-50ms) Lento (dipende dai dati) Buono (~40-80ms) Eccellente (~10-50ms)
LCP Eccellente Lento Buono Eccellente
Contenuto dinamico No (richiede rivalidazione) Sì (tutto dinamico) Sì (tutto dinamico) Sì (parti dinamiche selettive)
Personalizzazione No Sì (nelle parti dinamiche)
Carico sul server Nessuno (CDN) Alto (render completo) Alto (render completo) Basso (solo parti dinamiche)
Uso della CDN Completo Nessuno Nessuno Parziale (shell dalla CDN)
Esperienza di caricamento Istantanea Schermo bianco, poi tutto Progressiva con skeleton Shell istantanea + progressiva
Configurazione Default (nessuna API dinamica) Uso di cookies/headers Suspense boundaries cacheComponents + use cache

Quando Scegliere Quale Approccio

Cerchiamo di semplificare la scelta:

  • SSG: Contenuti che cambiano raramente (blog, documentazione, landing page). Nessuna personalizzazione per utente.
  • SSR Tradizionale: Francamente, da evitare quando possibile. Lo streaming è quasi sempre superiore nell'App Router.
  • Streaming SSR: Pagine completamente dinamiche dove ogni sezione dipende da dati in tempo reale e non ha senso cachare nulla.
  • PPR: Il caso più comune nelle applicazioni reali — pagine con un mix di contenuto statico e dinamico. E-commerce, dashboard, profili utente, feed social.

Pattern Avanzati per lo Streaming

Evitare il "Popcorn Effect"

Questo è un errore che ho visto fare (e fatto io stesso, devo ammetterlo) più volte di quanto vorrei. Il "popcorn effect" si verifica quando troppe Suspense boundaries indipendenti si risolvono in momenti diversi, causando transizioni visive frammentate. L'utente vede contenuti che "saltano fuori" in punti casuali della pagina, come chicchi di popcorn.

La regola empirica: un Suspense boundary per dipendenza dati indipendente, raggruppato per sezione visiva. Non avvolgete ogni singolo componente nel suo Suspense — raggruppate quelli che appartengono alla stessa sezione logica.

// SBAGLIATO: troppi Suspense boundary causano il popcorn effect
function Dashboard() {
  return (
    <div className="grid grid-cols-4 gap-4">
      <Suspense fallback={<Skeleton />}>
        <RevenueCard />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UsersCard />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <OrdersCard />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <ConversionCard />
      </Suspense>
    </div>
  )
}

// CORRETTO: raggruppare card correlate in un unico Suspense boundary
function Dashboard() {
  return (
    <div>
      {/* Le 4 card usano lo stesso endpoint, si raggruppano */}
      <Suspense fallback={<CardsSkeleton />}>
        <StatsCardsGroup />
      </Suspense>

      {/* Il grafico ha una fonte dati diversa */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </div>
  )
}

Parallel Data Fetching

Un errore altrettanto comune (e insidioso) è creare catene sequenziali di fetch senza rendersene conto. Quando più componenti all'interno dello stesso Suspense boundary devono recuperare dati, assicuratevi che i fetch avvengano in parallelo.

// SBAGLIATO: fetch sequenziale (waterfall) - il secondo aspetta il primo
async function StatsCards() {
  const revenue = await fetchRevenue()      // 200ms
  const users = await fetchActiveUsers()    // 150ms
  // Totale: 350ms

  return (
    <div className="grid grid-cols-2 gap-4">
      <Card title="Ricavi" value={revenue} />
      <Card title="Utenti Attivi" value={users} />
    </div>
  )
}

// CORRETTO: fetch parallelo con Promise.all
async function StatsCards() {
  const [revenue, users] = await Promise.all([
    fetchRevenue(),       // 200ms \
    fetchActiveUsers(),   // 150ms / in parallelo
  ])
  // Totale: 200ms (il massimo dei due, non la somma)

  return (
    <div className="grid grid-cols-2 gap-4">
      <Card title="Ricavi" value={revenue} />
      <Card title="Utenti Attivi" value={users} />
    </div>
  )
}

Streaming di Dati dal Server ai Client Component

Quando avete bisogno di passare dati asincroni dal server a un Client Component che gestisce interattività complessa, potete sfruttare l'API use di React per consumare una Promise passata come prop. È un pattern elegante che vale la pena conoscere.

// app/analytics/page.tsx - Server Component
import { InteractiveChart } from '@/components/InteractiveChart'

export default function AnalyticsPage() {
  // Iniziare il fetch senza await - passare la Promise
  const chartDataPromise = fetchChartData()

  return (
    <div>
      <h1>Analytics</h1>
      <Suspense fallback={<ChartSkeleton />}>
        <InteractiveChart dataPromise={chartDataPromise} />
      </Suspense>
    </div>
  )
}
// components/InteractiveChart.tsx - Client Component
'use client'

import { use, useState } from 'react'

export function InteractiveChart({ dataPromise }: { dataPromise: Promise<ChartData> }) {
  const data = use(dataPromise)
  const [timeRange, setTimeRange] = useState('7d')

  const filteredData = data.filter(d => isInRange(d.date, timeRange))

  return (
    <div>
      <div className="flex gap-2 mb-4">
        <button onClick={() => setTimeRange('7d')}>7 giorni</button>
        <button onClick={() => setTimeRange('30d')}>30 giorni</button>
        <button onClick={() => setTimeRange('90d')}>90 giorni</button>
      </div>
      <Chart data={filteredData} />
    </div>
  )
}

Il bello di questo pattern è che il fetch inizia sul server (senza await), la Promise viene serializzata e passata al Client Component, e quest'ultimo la consuma con use(). Il Suspense boundary si occupa di mostrare il fallback fino a quando la Promise non si risolve. Tutto molto fluido.

Performance: Impatto su TTFB, LCP e Core Web Vitals

Bene, parliamo di numeri. Lo streaming e il PPR hanno un impatto diretto e misurabile sulle metriche che contano davvero. Vediamo nel dettaglio come ciascuna viene influenzata.

Time to First Byte (TTFB)

Il TTFB misura quanto tempo passa tra la richiesta e il primo byte di risposta ricevuto dal browser. Google raccomanda un TTFB inferiore a 800ms al 75° percentile.

Con l'SSR tradizionale, il TTFB include tutto il tempo di rendering server-side: query al database, chiamate API, rendering dei componenti. Se il vostro database impiega 400ms per una query complessa, il TTFB sarà almeno 400ms. Non ci sono scorciatoie.

Con lo streaming, il server inizia a inviare HTML non appena ha qualcosa da mostrare. Il TTFB si riduce drasticamente perché il primo byte contiene il layout e gli skeleton loader, disponibili in pochi millisecondi.

Con PPR, è ancora meglio: la shell statica può essere servita direttamente dalla CDN edge, raggiungendo tempi nell'ordine di 10-50ms indipendentemente dalla complessità dei dati dinamici.

Largest Contentful Paint (LCP)

L'LCP misura quando l'elemento più grande nel viewport diventa visibile. Un LCP sotto i 2.5 secondi è considerato "buono" da Google.

Lo streaming migliora l'LCP perché il contenuto principale della pagina (spesso il titolo, l'immagine hero o il blocco di testo principale) può essere inviato nel primo chunk, senza aspettare che tutte le sezioni secondarie siano pronte. Se l'elemento LCP è nella shell statica, PPR lo rende disponibile quasi istantaneamente.

Cumulative Layout Shift (CLS)

Ecco un punto dolente. Lo streaming può causare layout shift se non gestito correttamente. Quando un contenuto dinamico sostituisce uno skeleton, la dimensione potrebbe essere diversa, causando spostamenti del layout. La soluzione? Progettare skeleton loader che abbiano le stesse dimensioni del contenuto reale, usando altezze e larghezze fisse dove possibile.

Interaction to Next Paint (INP)

Lo streaming migliora l'INP perché i Client Component nelle sezioni già caricate vengono idratati in parallelo con lo streaming delle sezioni successive. L'utente può interagire con le parti della pagina già pronte senza aspettare il completamento dello streaming.

Metriche Reali a Confronto

Metrica SSR Tradizionale Streaming SSR PPR
TTFB (p75) ~450ms ~45ms ~15ms (CDN edge)
LCP (p75) ~1200ms ~380ms ~200ms
CLS 0 (tutto arriva insieme) Rischio se mal gestito Minimo (shell stabile)
INP Alto (idratazione massiva) Buono (idratazione progressiva) Ottimo (shell già interattiva)
Miglioramento complessivo Baseline ~60-70% più veloce ~60-80% più veloce

SEO e Accessibilità con lo Streaming

Una preoccupazione che sento spesso: "Ma lo streaming non rovina la SEO?". La risposta breve è: no. Lo streaming è server-rendered, il che significa che tutto il contenuto viene generato sul server e incluso nella risposta HTML. I crawler dei motori di ricerca ricevono lo stesso contenuto completo che riceverebbe un browser.

Googlebot, in particolare, è in grado di processare contenuti in streaming. Attende il completamento dello stream prima di indicizzare la pagina, assicurandosi di avere l'HTML completo. Questo è molto diverso dal Client-Side Rendering, dove il contenuto viene generato via JavaScript nel browser e può non essere visto dai crawler.

Con PPR, la situazione è ancora più favorevole: la shell statica contiene già la struttura semantica della pagina, i meta tag, i titoli e buona parte del contenuto. I "buchi" dinamici vengono riempiti tramite streaming server-side, quindi anche il contenuto dinamico è disponibile per l'indicizzazione.

Accessibilità

Per l'accessibilità, ci sono alcune considerazioni importanti (che troppo spesso vengono ignorate):

  • Gestione del focus: Quando il contenuto in streaming sostituisce un fallback, assicuratevi che il focus dell'utente non venga perso o spostato in modo inaspettato. Utilizzate aria-live regions per annunciare i cambiamenti agli screen reader.
  • Skeleton loader significativi: I fallback Suspense dovrebbero comunicare che il contenuto è in fase di caricamento. Usate aria-busy="true" e testo alternativo per gli screen reader.
  • Ordine del contenuto: Lo streaming rispetta l'ordine del DOM, quindi il contenuto arriva nella sequenza corretta per la navigazione da tastiera e la lettura tramite screen reader.
  • Evitare salti di layout: I layout shift non sono solo un problema di performance — possono disorientare utenti con disabilità cognitive o motorie. Uno skeleton che cambia dimensione può far perdere completamente il contesto visivo.
// Esempio di skeleton loader accessibile
function AccessibleSkeleton({ label }: { label: string }) {
  return (
    <div
      role="status"
      aria-busy="true"
      aria-label={`Caricamento ${label} in corso`}
      className="animate-pulse bg-gray-100 rounded-lg"
    >
      <span className="sr-only">Caricamento {label} in corso...</span>
    </div>
  )
}

Best Practice e Errori Comuni

Best Practice

  1. Spostate i fetch il più vicino possibile ai componenti che li usano. Invece di recuperare tutti i dati in un componente genitore e passarli come props, lasciate che ogni Server Component recuperi i propri dati. Questo permette a ogni componente di avere il proprio Suspense boundary e caricarsi indipendentemente.
  2. Progettate skeleton che rispecchino il layout finale. Lo skeleton deve avere le stesse dimensioni approssimative del contenuto reale per minimizzare il CLS. Usate altezze fisse, grid layout coerenti e spaziature identiche.
  3. Raggruppate Suspense boundary per sezione visiva. Come regola generale: un Suspense boundary per ogni blocco visivamente distinto della pagina. Non per ogni singolo componente, non per l'intera pagina.
  4. Usate Promise.all per fetch paralleli. Quando più dati servono nello stesso Suspense boundary, avviate tutti i fetch contemporaneamente.
  5. Prioritizzate il contenuto above-the-fold. Strutturate i Suspense boundary in modo che il contenuto visibile nel viewport iniziale sia nel primo gruppo di dati recuperati. Il contenuto sotto la piega può caricarsi dopo.
  6. Misurate con dati reali. Utilizzate useReportWebVitals o strumenti RUM (Real User Monitoring) per monitorare le metriche di performance effettive. I test di laboratorio non raccontano tutta la storia.
  7. Con PPR, identificate chiaramente le parti statiche e dinamiche. Usate "use cache" con intenzione: tutto ciò che non dipende da cookies, headers o dati in tempo reale dovrebbe essere cachato nella shell statica.

Errori Comuni

  1. Dimenticare Suspense attorno ai componenti asincroni con PPR. In Next.js 16 con cacheComponents attivo, se un componente dinamico non è avvolto in <Suspense> né marcato con "use cache", otterrete un errore: Uncached data was accessed outside of <Suspense>. Non è il tipo di errore che volete scoprire in produzione.
  2. Creare waterfall di dati accidentali. Se un componente genitore fa un fetch e poi passa l'ID al componente figlio che fa un altro fetch, avete creato una cascata sequenziale. Ristrutturate in modo che i fetch possano avvenire in parallelo.
  3. Troppi Suspense boundary (popcorn effect). Dieci skeleton che appaiono e scompaiono in momenti diversi creano un'esperienza confusa. Raggruppate per sezione logica.
  4. Skeleton di dimensioni errate. Uno skeleton alto 100px che viene sostituito da contenuto alto 300px causa un layout shift significativo. Impatta sia il CLS che l'usabilità generale.
  5. Non testare con connessioni lente. Lo streaming brilla particolarmente con connessioni lente. Testate con throttling di rete per vedere l'esperienza dei vostri utenti reali — non solo con la fibra dell'ufficio.
  6. Usare cookies() o headers() nella shell con PPR. Se accedete a cookies() o headers() in un componente che dovrebbe essere statico, l'intera route diventa dinamica. Isolate l'accesso a queste API nei componenti dinamici avvolti in Suspense.
  7. Ignorare il costo dell'idratazione. Lo streaming riduce il TTFB ma non elimina il costo dell'idratazione JavaScript. Minimizzate i Client Component, usate Server Component dove possibile, e tenete d'occhio la quantità di JavaScript inviata al client. Il runtime minimo dell'App Router è circa 87 KB, quindi ogni kilobyte aggiuntivo conta.

Pattern Architetturale Completo: E-Commerce con PPR

Mettiamo insieme tutti i pezzi. Ecco un esempio completo di una pagina prodotto e-commerce che sfrutta PPR al massimo delle sue potenzialità.

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
// app/prodotti/[slug]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { ProductGallery } from '@/components/ProductGallery'
import { ProductInfo } from '@/components/ProductInfo'
import { PersonalizedRecommendations } from '@/components/PersonalizedRecommendations'
import { ProductReviews } from '@/components/ProductReviews'
import { CartActions } from '@/components/CartActions'
import { BreadcrumbNav } from '@/components/BreadcrumbNav'
import {
  GallerySkeleton,
  InfoSkeleton,
  RecommendationsSkeleton,
  ReviewsSkeleton,
  CartSkeleton,
} from '@/components/Skeletons'

// Metadati cachati per SEO
export async function generateMetadata({ params }: { params: { slug: string } }) {
  'use cache'
  const product = await getProduct(params.slug)
  if (!product) return {}

  return {
    title: `${product.name} | Il Nostro Negozio`,
    description: product.shortDescription,
    openGraph: { images: [product.mainImage] },
  }
}

export default async function ProductPage({ params }: { params: { slug: string } }) {
  return (
    <div className="max-w-7xl mx-auto px-4">
      {/* Breadcrumb: cachato, nella shell statica */}
      <Suspense fallback={<div className="h-6" />}>
        <BreadcrumbNav slug={params.slug} />
      </Suspense>

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mt-6">
        {/* Galleria immagini: cachata, nella shell statica */}
        <Suspense fallback={<GallerySkeleton />}>
          <ProductGallery slug={params.slug} />
        </Suspense>

        <div className="space-y-6">
          {/* Info prodotto: cachate, nella shell statica */}
          <Suspense fallback={<InfoSkeleton />}>
            <ProductInfo slug={params.slug} />
          </Suspense>

          {/* Azioni carrello: DINAMICHE (dipendono dalla sessione) */}
          <Suspense fallback={<CartSkeleton />}>
            <CartActions slug={params.slug} />
          </Suspense>
        </div>
      </div>

      {/* Recensioni: DINAMICHE (aggiornate in tempo reale) */}
      <section className="mt-12">
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews slug={params.slug} />
        </Suspense>
      </section>

      {/* Raccomandazioni: DINAMICHE (personalizzate per utente) */}
      <section className="mt-12">
        <Suspense fallback={<RecommendationsSkeleton />}>
          <PersonalizedRecommendations slug={params.slug} />
        </Suspense>
      </section>
    </div>
  )
}
// components/ProductGallery.tsx - CACHATO nella shell statica
import { cacheLife, cacheTag } from 'next/cache'

export async function ProductGallery({ slug }: { slug: string }) {
  'use cache'
  cacheLife('days')
  cacheTag(`product-${slug}`)

  const product = await db.product.findUnique({
    where: { slug },
    select: { images: true, name: true },
  })

  if (!product) return null

  return (
    <div className="space-y-4">
      <img
        src={product.images[0]}
        alt={`Immagine principale di ${product.name}`}
        className="w-full rounded-xl"
      />
      <div className="grid grid-cols-4 gap-2">
        {product.images.slice(1).map((img, i) => (
          <img
            key={i}
            src={img}
            alt={`${product.name}, immagine ${i + 2}`}
            className="rounded-lg cursor-pointer hover:opacity-80"
          />
        ))}
      </div>
    </div>
  )
}
// components/PersonalizedRecommendations.tsx - DINAMICO
import { cookies } from 'next/headers'

export async function PersonalizedRecommendations({ slug }: { slug: string }) {
  const sessionId = cookies().get('session')?.value
  const recommendations = await getPersonalizedRecommendations(slug, sessionId)

  return (
    <div>
      <h2 className="text-xl font-bold mb-4">Consigliati per Te</h2>
      <div className="grid grid-cols-4 gap-4">
        {recommendations.map(product => (
          <a key={product.id} href={`/prodotti/${product.slug}`} className="group">
            <img
              src={product.image}
              alt={product.name}
              className="rounded-lg group-hover:shadow-lg transition-shadow"
            />
            <p className="mt-2 font-medium">{product.name}</p>
            <p className="text-gray-600">&euro;{product.price}</p>
          </a>
        ))}
      </div>
    </div>
  )
}

In questa architettura, al momento del build Next.js pre-renderizza il layout generale, i breadcrumb, la galleria immagini, le informazioni del prodotto e tutti gli skeleton loader. Al momento della richiesta, il server invia immediatamente questa shell dalla CDN, poi renderizza in parallelo le azioni del carrello, le recensioni e le raccomandazioni personalizzate, inviandole tramite streaming man mano che sono pronte.

È un approccio che funziona davvero bene in produzione.

Monitorare e Misurare le Performance

Implementare streaming e PPR senza misurazione è un po' come navigare alla cieca. Per fortuna, Next.js offre strumenti integrati per monitorare le Core Web Vitals.

// app/components/WebVitalsReporter.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    // Inviare a un servizio di analytics
    const body = JSON.stringify({
      name: metric.name,      // 'TTFB', 'LCP', 'CLS', 'INP', 'FCP'
      value: metric.value,
      rating: metric.rating,  // 'good', 'needs-improvement', 'poor'
      navigationType: metric.navigationType,
    })

    // Usare sendBeacon per non bloccare la navigazione
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/analytics/vitals', body)
    }
  })

  return null
}
// app/layout.tsx
import { WebVitalsReporter } from '@/components/WebVitalsReporter'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="it">
      <body>
        <WebVitalsReporter />
        {children}
      </body>
    </html>
  )
}

Un consiglio che mi sento di dare: confrontate sempre i dati di laboratorio (Lighthouse, Chrome DevTools) con i dati di campo (CrUX, RUM personalizzato). Le condizioni di rete reali dei vostri utenti sono spesso molto diverse da quelle del vostro ambiente di sviluppo. Fidatevi dei numeri del campo, non del laboratorio.

Conclusioni e il Futuro del Rendering in Next.js

Lo streaming SSR e il Partial Prerendering rappresentano un vero salto qualitativo nel modo in cui costruiamo applicazioni web. Non si tratta più di scegliere tra "statico veloce" e "dinamico personalizzato" — PPR ci permette di avere entrambi nella stessa pagina, nella stessa richiesta HTTP.

Ricapitoliamo i punti fondamentali:

  • Lo streaming trasforma il rendering da bloccante a progressivo, eliminando lo schermo bianco e migliorando drasticamente TTFB e LCP.
  • loading.js offre streaming automatico a livello di route, mentre Suspense manuale offre controllo granulare su quali sezioni caricare indipendentemente.
  • PPR unisce il meglio di SSG e SSR: shell statica dalla CDN + contenuto dinamico in streaming, tutto in un'unica risposta HTTP.
  • I Cache Components in Next.js 16 completano la storia di PPR con un modello di caching esplicito e opt-in tramite la direttiva "use cache".
  • I pattern avanzati come il raggruppamento delle Suspense boundaries, il parallel data fetching e lo streaming verso Client Component con l'API use() sono essenziali per un'implementazione che funzioni davvero bene.
  • La misurazione continua delle Core Web Vitals è indispensabile per validare che le ottimizzazioni stiano effettivamente migliorando l'esperienza degli utenti reali.

Guardando al futuro, la direzione è chiara: Next.js sta convergendo verso un modello dove il rendering è dinamico per default e lo sviluppatore opta esplicitamente nel caching dove ha senso. I Cache Components con "use cache" rappresentano questa filosofia — niente più caching implicito che sorprende gli sviluppatori, ma un controllo chiaro e intenzionale su cosa viene cachato, per quanto tempo, e come viene rivalidato.

Se state costruendo un'applicazione oggi, il mio consiglio è pragmatico: iniziate con lo streaming (Suspense boundaries ben posizionati e skeleton loader curati), poi quando la vostra applicazione e il framework saranno pronti, il passaggio a PPR sarà naturale — i Suspense boundary che avete già definito diventeranno automaticamente i confini tra shell statica e buchi dinamici. Questa è la bellezza dell'architettura: lo streaming è la base su cui PPR è costruito, e investire oggi nei pattern corretti vi prepara per le ottimizzazioni di domani.

Sull'Autore Editorial Team

Our team of expert writers and editors.