React Server Components: Architettura Streaming, Costi Prestazionali e Pattern di Migrazione
Ciclo di Vita di una Richiesta RSC
Ecco cosa succede effettivamente quando un browser richiede una pagina con RSC:
- Il server riceve la richiesta e inizia a renderizzare l'albero dei componenti
- I Server Components si eseguono sul server — possono fare
awaitdi query al database, letture file, chiamate API direttamente - Quando il server incontra un boundary
'use client', smette di renderizzare quel sottoalbero e emette un riferimento - Il server fa lo streaming dell'output come formato speciale chiamato RSC payload
- Il runtime React lato client riceve questo payload, idrata i client components e assembla tutto
L'intuizione chiave: l'RSC payload viene streamato. Il server non aspetta che tutti i dati si risolvano prima di inviare il primo byte. I Suspense boundaries definiscono i chunk dello streaming.
Il Costo di 'use client'
Ecco cosa sfugge alla gente: 'use client' non significa solo "questo componente gira sul client." Significa questo componente E ogni componente che importa diventa parte del bundle client. È una dichiarazione di boundary, non un toggle per componente.
// Questo singolo 'use client' trascina tutto nel bundle client
'use client';
import { HeavyChartLibrary } from 'heavy-charts'; // 200KB
import { DateFormatter } from './utils'; // 2KB
import { UserAvatar } from './UserAvatar'; // 5KB
export function Dashboard({ data }) {
const [filter, setFilter] = useState('all');
// ...
}
La soluzione è decomposizione chirurgica:
// ServerDashboard.jsx (server component)
import { HeavyChartLibrary } from 'heavy-charts'; // resta sul server!
import { DateFormatter } from './utils'; // resta sul server!
import { DashboardFilter } from './DashboardFilter'; // boundary client
export async function ServerDashboard() {
const data = await db.metrics.getAll(); // accesso diretto al DB
return (
<div>
<h1>Dashboard</h1>
<DashboardFilter />
<HeavyChartLibrary data={data} interactive={false} />
<p>Ultimo aggiornamento: {DateFormatter.relative(data.updatedAt)}</p>
</div>
);
}
// DashboardFilter.jsx
'use client';
import { useState } from 'react';
export function DashboardFilter() {
const [filter, setFilter] = useState('all');
return (
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Tutti</option>
<option value="active">Attivi</option>
<option value="archived">Archiviati</option>
</select>
);
}
Quella HeavyChartLibrary non arriva mai al client. In una migrazione di produzione reale, questo pattern ha tagliato il JS client-side della dashboard principale da 340KB a 89KB (gzipped). Il First Contentful Paint è migliorato di 1.2 secondi su connessione 4G mediana.
Streaming SSR con Suspense: Il Setup Pratico
// app/page.jsx (server component di default in Next.js App Router)
import { Suspense } from 'react';
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<price>{product.price}</price>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationsPanel productId={params.id} />
</Suspense>
</main>
);
}
async function ProductReviews({ productId }) {
const reviews = await getReviews(productId);
return (
<section>
<h2>Recensioni ({reviews.length})</h2>
{reviews.map(r => (
<ReviewCard key={r.id} review={r} />
))}
</section>
);
}
Cosa vede l'utente:
- Istantaneo: Nome prodotto, descrizione, prezzo + placeholder skeleton
- ~800ms: Le recensioni appaiono, sostituendo lo skeleton
- ~1500ms: Il pannello raccomandazioni si riempie
Server Actions: RPC Integrato per React
// actions.js
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createComment(formData) {
const content = formData.get('content');
const postId = formData.get('postId');
if (!content || content.length > 5000) {
return { error: 'Il commento deve essere tra 1 e 5000 caratteri' };
}
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
await db.comments.create({
content,
postId,
authorId: user.id,
createdAt: new Date(),
});
revalidatePath(`/posts/${postId}`);
return { success: true };
}
// CommentForm.jsx
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
export function CommentForm({ postId }) {
const [state, formAction, isPending] = useActionState(createComment, null);
return (
<form action={formAction}>
<input type="hidden" name="postId" value={postId} />
<textarea
name="content"
placeholder="Scrivi un commento..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Pubblicando...' : 'Pubblica'}
</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}
La cosa bella: questo form funziona senza JavaScript. Il <form action={formAction}> fa progressive enhancement — senza JS, invia come un POST normale. Con JS, React lo intercetta, manda la richiesta in background e aggiorna la UI ottimisticamente.
Tre form sono stati messi in produzione con questo pattern e la storia del progressive enhancement è reale. Su connessioni lente dove il JS non ha ancora caricato, gli utenti possono comunque inviare.
Pattern di Data Fetching Lato Server
Dimenticate useEffect per il data fetching. Dimenticate react-query per i caricamenti iniziali:
async function UserProfile({ userId }) {
const [user, posts, stats] = await Promise.all([
db.users.findById(userId),
db.posts.findByAuthor(userId, { limit: 10 }),
analytics.getUserStats(userId),
]);
return (
<div>
<h1>{user.name}</h1>
<StatsBanner stats={stats} />
<PostList posts={posts} />
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed userId={userId} />
</Suspense>
</div>
);
}
Nessun loading state per il contenuto principale. Nessun waterfall request. Nessun boilerplate useEffect + useState + isLoading.
Il Promise.all è importante — senza di esso, le tre query girano in sequenza. Questo errore appare costantemente nelle code review.
Strategia di Migrazione: L'Approccio Strangler Fig
Non riscrivete la vostra app. Spostate gradualmente i componenti al server:
Passo 1: Identificate le dipendenze client-side più pesanti con npx @next/bundle-analyzer.
Passo 2: Per ogni componente pesante, chiedetevi: "Questo ha bisogno di interattività?"
Passo 3: Estraete le parti interattive in client components minimali.
// Prima: componente intero client-side
'use client';
import { marked } from 'marked'; // 35KB
import DOMPurify from 'dompurify'; // 20KB
export function Article({ markdown }) {
const [fontSize, setFontSize] = useState(16);
const html = DOMPurify.sanitize(marked.parse(markdown));
return (
<div style={{ fontSize }}>
<FontSizeControl value={fontSize} onChange={setFontSize} />
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
// Dopo: processing pesante sul server, wrapper interattivo minimo
import { marked } from 'marked'; // non arriva mai al client
import DOMPurify from 'dompurify'; // non arriva mai al client
import { FontSizeWrapper } from './FontSizeWrapper';
export function ArticleContent({ markdown }) {
const html = DOMPurify.sanitize(marked.parse(markdown));
return (
<FontSizeWrapper>
<div dangerouslySetInnerHTML={{ __html: html }} />
</FontSizeWrapper>
);
}
// FontSizeWrapper.jsx (client component — 0.5KB)
'use client';
import { useState } from 'react';
export function FontSizeWrapper({ children }) {
const [fontSize, setFontSize] = useState(16);
return (
<div style={{ fontSize }}>
<button onClick={() => setFontSize(f => f - 2)}>A-</button>
<button onClick={() => setFontSize(f => f + 2)}>A+</button>
{children}
</div>
);
}
Metriche di Performance Misurate
Su una pagina prodotto e-commerce reale (Next.js 15, App Router, deployed su Vercel):
| Metrica | Pages Router (CSR) | App Router (RSC + Streaming) |
|---|---|---|
| FCP | 2.1s | 0.8s |
| LCP | 3.4s | 1.2s |
| TTI | 4.2s | 1.8s |
| Client JS (gzip) | 287KB | 112KB |
| TTFB | 180ms | 210ms |
Il TTFB è leggermente peggiore con RSC perché il server fa più lavoro prima di inviare il primo byte. Ma tutto il resto è drammaticamente migliore.
I Bordi Taglienti
Serialization boundary: Le props passate da server a client components devono essere serializzabili. Niente funzioni, classi, oggetti Date (usate stringhe ISO), Map/Set.
Librerie di terze parti: La maggior parte delle librerie React assume un ambiente client. Se importate una libreria che accede a window o document in un server component, otterrete un errore di build.
Debugging: Il formato RSC payload non è leggibile. Quando qualcosa va storto nel passaggio server-to-client, i messaggi di errore sono criptici.
Quando NON Usare RSC
Non tutto dovrebbe essere un server component. UI altamente interattive — builder drag-and-drop, editor collaborativi real-time, tool basati su canvas — restano migliori come applicazioni client-side. RSC brilla per pagine ricche di contenuto con isole di interattività, che è francamente la maggior parte del web.
Il modello mentale chiave: se si stanno renderizzando dati, usare un server component. Se si sta gestendo stato, usare un client component. Il confine tra loro è dove l'architettura diventa interessante, e azzeccare quel confine è ciò che separa un'app RSC veloce da una lenta che sta semplicemente facendo SSR con passi extra.