artCode

React Server Components: quando usare client vs server

Quando usare Client vs Server Components in React: benchmark reali, best practices Next.js 15, TypeScript ed Edge Runtime.

Tags

  • Javascript
  • Node.js

Published

Reading Time

14 min
React Server Components: decision flowchart showing when to use client vs server components in Next.js 15

Nel 2025, lo sviluppo React è entrato in una nuova era con i React Server Components (RSC) e Next.js 15. La domanda che ogni sviluppatore si pone è: quando usare Server Components e quando usare Client Components? Questa guida ti fornirà le risposte definitive, basate su benchmark reali, best practices e la mia esperienza diretta con progetti in produzione.

La rivoluzione dei React Server Components

I React Server Components rappresentano un cambio di paradigma fondamentale. A differenza del tradizionale Server-Side Rendering (SSR) che richiede l'hydration completa di JavaScript sul client, gli RSC rimangono completamente sul server, inviando solo HTML serializzato al browser.

TypeScript: lo standard del 2025

Prima di addentrarci nei Server Components, è importante sottolineare che TypeScript sta raggiungendo l'80% di adozione previsto tra gli sviluppatori nel 2025, consolidandosi come standard de facto per progetti React enterprise. Next.js 15 offre supporto TypeScript nativo out-of-the-box, con type inference automatico tra server e client components.

Tutti gli esempi in questa guida useranno TypeScript, riflettendo le best practices del 2025.

Next.js 15: Server Components by default

In Next.js 15 con l'App Router, ogni componente è un Server Component per default. Questo è un cambio importante rispetto al modello tradizionale di React dove tutto era client-side.

TypeScript
// app/page.tsx // Questo è un Server Component (default in Next.js 15) async function getData() { const res = await fetch('https://api.example.com/posts', { cache: 'no-store' // Dynamic data }) return res.json() } export default async function HomePage() { const posts = await getData() return ( <main> <h1>Blog Posts</h1> <ul> {posts.map((post: any) => ( <li key={post.id}>{post.title}</li> ))} </ul> </main> ) }

Nota come il componente sia una async function e usi await direttamente nel corpo. Questo è possibile solo nei Server Components e semplifica enormemente il data fetching rispetto ai pattern useEffect + useState tradizionali.

Quando usare Server Components

I Server Components sono la scelta ideale per:

  • Contenuto statico o data-heavy: Blog, documentazione, dashboard con dati server-side
  • SEO-critical pages: Pre-rendering completo per ottima indicizzazione
  • Accesso diretto a risorse server: Database queries, filesystem, variabili d'ambiente
  • Componenti che non richiedono interattività: Layout, header, footer, card informative
  • Riduzione bundle size: Librerie pesanti (markdown parser, date-fns, lodash) rimangono sul server

Esempio pratico: Dashboard con data fetching

TypeScript
// app/dashboard/page.tsx import { db } from '@/lib/database' import { Suspense } from 'react' import { Skeleton } from '@/components/ui/skeleton' // Server Component con accesso diretto al database async function UserStats({ userId }: { userId: string }) { // Query diretta al database - nessun API endpoint necessario const stats = await db.query( 'SELECT COUNT(*) as total, SUM(revenue) as revenue FROM orders WHERE user_id = ?', [userId] ) return ( <div className="stats-card"> <h2>Statistiche Ordini</h2> <p>Totale: {stats.total}</p> <p>Revenue: €{stats.revenue}</p> </div> ) } export default function Dashboard() { return ( <div className="dashboard"> <h1>Dashboard</h1> <Suspense fallback={<Skeleton />}> <UserStats userId="123" /> </Suspense> </div> ) }

In questo esempio, il componente UserStats accede direttamente al database senza bisogno di creare un API endpoint separato. Il tutto con zero JavaScript inviato al client per questo componente.

Quando usare Client Components

I Client Components sono necessari quando hai bisogno di:

  • Interattività: Event handlers come onClick, onChange, onSubmit
  • React Hooks: useState, useEffect, useContext, useReducer, custom hooks
  • Browser APIs: window, localStorage, geolocation, IntersectionObserver
  • Real-time features: WebSocket connections, polling, subscriptions
  • Librerie client-only: Chart.js, react-spring (animations), third-party widgets

Per dichiarare un Client Component, aggiungi 'use client' all'inizio del file:

TypeScript
// components/Counter.tsx 'use client' import { useState } from 'react' export function Counter() { const [count, setCount] = useState(0) return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ) }

Esempio pratico: Form con validazione real-time

TypeScript
// components/ContactForm.tsx 'use client' import { useState, FormEvent } from 'react' import { z } from 'zod' const contactSchema = z.object({ email: z.string().email('Email non valida'), message: z.string().min(10, 'Messaggio troppo corto') }) export function ContactForm() { const [email, setEmail] = useState('') const [message, setMessage] = useState('') const [errors, setErrors] = useState<Record<string, string>>({}) const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit = async (e: FormEvent) => { e.preventDefault() setIsSubmitting(true) // Validazione Zod lato client const result = contactSchema.safeParse({ email, message }) if (!result.success) { const fieldErrors: Record<string, string> = {} result.error.errors.forEach(err => { if (err.path[0]) { fieldErrors[err.path[0] as string] = err.message } }) setErrors(fieldErrors) setIsSubmitting(false) return } // Invio al server await fetch('/api/contact', { method: 'POST', body: JSON.stringify({ email, message }) }) setIsSubmitting(false) setEmail('') setMessage('') } return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="tua@email.com" /> {errors.email && <span className="error">{errors.email}</span>} <textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Il tuo messaggio" /> {errors.message && <span className="error">{errors.message}</span>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Invio...' : 'Invia'} </button> </form> ) }

Questo form richiede 'use client' perché usa useState per gestire lo stato, onChange handlers per l'input utente, e validazione real-time con feedback immediato.

Regole di composizione: mixing Server e Client Components

Una delle parti più importanti da capire è come comporre insieme Server e Client Components. Esistono regole precise:

Regole di composizione tra Server e Client Components
PatternSupportatoSpiegazione
Server Component → Client Component✅ SìUn Server Component può importare e renderizzare Client Components
Client Component → Server Component❌ No (import diretto)Non puoi importare un Server Component dentro un Client Component
Client Component → Server Component (via children/props)✅ SìPuoi passare Server Components come children o props a Client Components
Server Component → Server Component✅ SìServer Components possono importare altri Server Components
Client Component → Client Component✅ SìClient Components possono importare altri Client Components

Pattern corretto: Client Wrapper con Server Children

TypeScript
// components/ClientWrapper.tsx 'use client' import { useState } from 'react' interface ClientWrapperProps { children: React.ReactNode } export function ClientWrapper({ children }: ClientWrapperProps) { const [isOpen, setIsOpen] = useState(false) return ( <div> <button onClick={() => setIsOpen(!isOpen)}> Toggle Content </button> {isOpen && ( <div className="content"> {children} </div> )} </div> ) } // app/page.tsx (Server Component) import { ClientWrapper } from '@/components/ClientWrapper' // Questo è un Server Component async function ServerContent() { const data = await fetch('https://api.example.com/data') const json = await data.json() return <div>{json.title}</div> } export default function Page() { return ( <ClientWrapper> <ServerContent /> </ClientWrapper> ) }

In questo pattern, il ClientWrapper gestisce l'interattività (toggle button), mentre ServerContent rimane un Server Component che fa fetch di dati. Passando il Server Component come children, manteniamo la separazione tra server e client boundary.

Performance comparison: CSR vs SSR vs RSC vs Edge

I benchmark 2025 mostrano differenze drammatiche nelle performance tra i diversi approcci di rendering. Analizziamo i dati reali:

Performance metrics comparison (benchmark 2025)
RenderingPerformance ScoreTTI (Time to Interactive)LCP (Largest Contentful Paint)Bundle JSUse Case ideale
CSR (Client-Side)784.3s3.9sGrande (~500KB)Dashboard interni, SPA admin
SSR (Server-Side)863.2s0.9sMedio (~300KB)Blog tradizionali, e-commerce
RSC (Server Components)971.6s0.8sPiccolo (~100KB)Applicazioni moderne, content-heavy
Edge (RSC + Edge Runtime)990.8s0.5sPiccolo (~100KB)Applicazioni globali, latency-critical

Spiegazione dei risultati

CSR (Client-Side Rendering) soffre di TTFB basso ma TTI alto: il browser deve scaricare, parsare ed eseguire tutto il JavaScript prima che l'app sia interattiva. Va bene per dashboard interne dove SEO non è critico.

SSR (Server-Side Rendering) migliora drasticamente LCP (pagina visibile subito) ma mantiene un gap di interattività durante l'hydration. Il JavaScript deve comunque essere scaricato e processato per rendere la pagina interattiva.

RSC (React Server Components) elimina il gap di interattività perché solo i Client Components richiedono JavaScript. Il resto è HTML puro, ottenendo un TTI di 1.6s con bundle ridotti fino al 50-70% rispetto al CSR in applicazioni content-heavy dove la maggior parte dei componenti può rimanere sul server.

Edge (RSC + Edge Runtime) porta i benefici degli RSC a livello globale, eseguendo il rendering fisicamente vicino agli utenti. Può ridurre la latenza di 150-300ms rispetto al SSR tradizionale da server centralizzato, particolarmente efficace per utenti geograficamente distanti dal server origin.

Edge Runtime in Next.js 15

Next.js 15 supporta completamente Edge Runtime, permettendo di eseguire Server Components su edge network (Vercel Edge, Cloudflare Workers). Questo porta il rendering più vicino agli utenti finali, riducendo drammaticamente la latenza.

TypeScript
// app/products/[id]/page.tsx export const runtime = 'edge' // Deploy su Edge Runtime interface PageProps { params: { id: string } } async function getProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`, { next: { revalidate: 3600 } // Cache per 1 ora }) return res.json() } export default async function ProductPage({ params }: PageProps) { const product = await getProduct(params.id) return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <p>Prezzo: €{product.price}</p> </div> ) }

Con export const runtime = 'edge', questa pagina viene deployata su edge locations globalmente distribuite. Un utente in Italia riceve il rendering da un edge server in Europa, mentre un utente in Giappone lo riceve da un server in Asia.

Quando Edge Runtime NON conviene

Edge Runtime non è sempre la scelta migliore. Ecco un esempio di quando può peggiorare la performance:

TypeScript
// ❌ NON IDEALE per Edge Runtime export const runtime = 'edge' export default async function Page() { // Database PostgreSQL in us-east-1 (Virginia) // Utenti principalmente europei // Edge function in EU deve fare round-trip a US per ogni query const products = await db.query('SELECT * FROM products LIMIT 100') const categories = await db.query('SELECT * FROM categories') // Risultato: latency MAGGIORE rispetto a SSR tradizionale // Edge EU → DB US (150ms) + Query (50ms) + Response (150ms) = 350ms // vs SSR da server EU vicino al DB: 20ms totale return <div>{/* render products */}</div> } // ✅ MEGLIO: SSR tradizionale o Edge + caching export default async function Page() { // Opzione 1: SSR tradizionale con server vicino al database // Opzione 2: Edge + dati cachati (Vercel KV, Cloudflare KV) const products = await kv.get('products') // Cache edge-local return <div>{/* render products */}</div> }

Streaming e Suspense avanzato

Next.js 15 con React 19 introduce streaming migliorato che permette di inviare HTML progressivamente al browser mentre viene generato sul server. Questo riduce il perceived loading time.

TypeScript
// app/dashboard/page.tsx import { Suspense } from 'react' // Componente veloce - renderizzato immediatamente function QuickStats() { return <div>Statistiche immediate</div> } // Componente lento - streaming differito async function SlowAnalytics() { // Simula query complessa al database await new Promise(resolve => setTimeout(resolve, 3000)) const analytics = await getComplexAnalytics() return ( <div> <h2>Analytics Dettagliata</h2> <pre>{JSON.stringify(analytics, null, 2)}</pre> </div> ) } export default function Dashboard() { return ( <div className="dashboard"> <h1>Dashboard</h1> {/* Renderizzato immediatamente */} <QuickStats /> {/* Streamed progressivamente quando pronto */} <Suspense fallback={<div>Caricamento analytics...</div>}> <SlowAnalytics /> </Suspense> </div> ) }

Con questo pattern, l'utente vede immediatamente QuickStats e il titolo della dashboard, mentre SlowAnalytics viene streamato quando i dati sono pronti. Non c'è bisogno di aspettare che tutto sia pronto prima di mostrare la pagina.

Flowchart decisionale: quando usare cosa

Ho creato un flowchart pratico per decidere rapidamente quale tipo di componente usare:

  • Ha bisogno di interattività? (onClick, onChange, ecc.) → Client Component
  • Usa React hooks? (useState, useEffect, useContext) → Client Component
  • Accede a Browser APIs? (window, localStorage, etc.) → Client Component
  • Fa data fetching da database/API? → Server Component
  • È contenuto statico o SEO-critico? → Server Component
  • Usa librerie pesanti che possono rimanere sul server? → Server Component
  • Necessita di latenza minima globale? → Server Component + Edge Runtime

Caso studio reale: ottimizzazione e-commerce

Vediamo un esempio pratico di come strutturare una pagina prodotto e-commerce usando il mix ottimale di Server e Client Components:

TypeScript
// app/products/[slug]/page.tsx (Server Component) import { AddToCartButton } from '@/components/AddToCartButton' import { ProductImages } from '@/components/ProductImages' import { RelatedProducts } from '@/components/RelatedProducts' import { db } from '@/lib/database' export const runtime = 'edge' interface PageProps { params: { slug: string } } // Server Component - accesso diretto al database async function getProduct(slug: string) { return await db.product.findUnique({ where: { slug }, include: { reviews: true, inventory: true } }) } async function getRelatedProducts(categoryId: string) { return await db.product.findMany({ where: { categoryId }, take: 4 }) } export default async function ProductPage({ params }: PageProps) { // Data fetching parallelo const [product, relatedProducts] = await Promise.all([ getProduct(params.slug), getRelatedProducts(product.categoryId) ]) return ( <div className="product-page"> {/* Client Component - immagini interattive con zoom */} <ProductImages images={product.images} /> {/* Server Component - informazioni statiche */} <div className="product-info"> <h1>{product.name}</h1> <p>{product.description}</p> <p className="price">€{product.price}</p> {/* Client Component - button interattivo con stato */} <AddToCartButton productId={product.id} /> </div> {/* Server Component - reviews dal database */} <div className="reviews"> <h2>Recensioni ({product.reviews.length})</h2> {product.reviews.map(review => ( <div key={review.id}> <p>{review.text}</p> <span>⭐ {review.rating}/5</span> </div> ))} </div> {/* Server Component - prodotti correlati */} <RelatedProducts products={relatedProducts} /> </div> ) }
TypeScript
// components/AddToCartButton.tsx (Client Component) 'use client' import { useState } from 'react' import { useCart } from '@/hooks/useCart' interface AddToCartButtonProps { productId: string } export function AddToCartButton({ productId }: AddToCartButtonProps) { const [quantity, setQuantity] = useState(1) const [isAdding, setIsAdding] = useState(false) const { addItem } = useCart() const handleAddToCart = async () => { setIsAdding(true) await addItem(productId, quantity) setIsAdding(false) } return ( <div className="add-to-cart"> <input type="number" value={quantity} onChange={(e) => setQuantity(Number(e.target.value))} min={1} /> <button onClick={handleAddToCart} disabled={isAdding}> {isAdding ? 'Aggiunta...' : 'Aggiungi al carrello'} </button> </div> ) }
TypeScript
// components/ProductImages.tsx (Client Component) 'use client' import { useState } from 'react' import Image from 'next/image' interface ProductImagesProps { images: { url: string; alt: string }[] } export function ProductImages({ images }: ProductImagesProps) { const [selectedIndex, setSelectedIndex] = useState(0) const [isZoomed, setIsZoomed] = useState(false) return ( <div className="product-images"> <div className="main-image" onClick={() => setIsZoomed(!isZoomed)} > <Image src={images[selectedIndex].url} alt={images[selectedIndex].alt} width={600} height={600} className={isZoomed ? 'zoomed' : ''} /> </div> <div className="thumbnails"> {images.map((image, index) => ( <button key={index} onClick={() => setSelectedIndex(index)} className={selectedIndex === index ? 'active' : ''} > <Image src={image.url} alt={image.alt} width={100} height={100} /> </button> ))} </div> </div> ) }

In questo caso studio:

  • Server Component (page.tsx): Fa fetch dei dati dal database, renderizza layout e contenuto statico
  • Client Component (ProductImages): Gestisce interattività (selezione thumbnail, zoom)
  • Client Component (AddToCartButton): Gestisce stato carrello e onClick handlers
  • Edge Runtime: Deploy globale per latenza minima
  • Bundle risultante: ~150KB JavaScript (solo client components) vs ~600KB con CSR tradizionale per questa specifica implementazione

Best practices e pattern comuni

Ecco le best practices che dovresti seguire quando lavori con Server e Client Components:

1. Colocation intelligente dei Client Components

Invece di marcare un intero componente come client, isola solo le parti interattive:

TypeScript
// ❌ NON OTTIMALE - tutto il componente è client 'use client' import { useState } from 'react' export function Article({ content }) { const [likes, setLikes] = useState(0) return ( <article> {/* Markdown pesante renderizzato client-side */} <MarkdownRenderer content={content} /> <button onClick={() => setLikes(likes + 1)}> ❤️ {likes} </button> </article> ) } // ✅ OTTIMALE - solo il button interattivo è client // components/LikeButton.tsx 'use client' import { useState } from 'react' export function LikeButton() { const [likes, setLikes] = useState(0) return ( <button onClick={() => setLikes(likes + 1)}> ❤️ {likes} </button> ) } // components/Article.tsx (Server Component) import { LikeButton } from './LikeButton' import { MarkdownRenderer } from './MarkdownRenderer' // Rimane sul server export function Article({ content }) { return ( <article> <MarkdownRenderer content={content} /> <LikeButton /> </article> ) }

Nel secondo esempio, la libreria markdown (pesante) rimane sul server, mentre solo il piccolo LikeButton è client-side.

2. Data fetching nei Server Components

Quando possibile, fai il fetch dei dati nei Server Components e passa solo i dati necessari ai Client Components:

TypeScript
// ✅ PATTERN OTTIMALE // app/users/[id]/page.tsx (Server Component) async function getUser(id: string) { const user = await db.user.findUnique({ where: { id } }) return user } export default async function UserPage({ params }: { params: { id: string } }) { const user = await getUser(params.id) // Passa solo i dati necessari al Client Component return ( <div> <h1>{user.name}</h1> <UserProfileForm initialEmail={user.email} initialBio={user.bio} /> </div> ) }

3. Gestione errori e loading states

Next.js 15 offre file speciali loading.tsx e error.tsx per gestire stati di caricamento ed errori a livello di route:

TypeScript
// app/dashboard/loading.tsx export default function Loading() { return ( <div className="loading-skeleton"> <div className="skeleton-header" /> <div className="skeleton-content" /> </div> ) } // app/dashboard/error.tsx 'use client' // Error boundaries devono essere client components export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div> <h2>Qualcosa è andato storto!</h2> <p>{error.message}</p> <button onClick={reset}>Riprova</button> </div> ) }

4. Context Providers in App Router

I Context Providers richiedono 'use client', ma puoi usare il pattern wrapper per minimizzare l'impatto:

TypeScript
// app/providers.tsx 'use client' import { ThemeProvider } from '@/contexts/ThemeContext' import { AuthProvider } from '@/contexts/AuthContext' export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider> <AuthProvider> {children} </AuthProvider> </ThemeProvider> ) } // app/layout.tsx (Server Component) import { Providers } from './providers' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html> <body> <Providers> {children} </Providers> </body> </html> ) }

Limitations e trade-offs da conoscere

Trade-offs di Edge Runtime

Edge Runtime offre latenza ridotta ma ha limitazioni importanti da considerare:

  • API Node.js limitate: Non tutte le API Node.js standard sono disponibili (es. fs, child_process)
  • Database location: Se il database è centralizzato, i benefici di latency sono ridotti
  • Costi: Edge Runtime può avere pricing diverso rispetto a server tradizionali
  • Cold starts: Anche se minimi, esistono cold starts su edge functions
  • Debugging: Più complesso debuggare problemi su edge distribuito

Quando NON usare React Server Components

  • Applicazioni altamente interattive: Dashboard real-time, editor complessi, gaming
  • Offline-first apps: Progressive Web Apps che devono funzionare offline
  • Client-heavy state management: Applicazioni con Redux/Zustand complessi
  • Quando il team non ha esperienza: Curva di apprendimento non trascurabile
  • Progetti legacy in migrazione: Potrebbe essere costoso migrare architetture esistenti

Debugging: identificare Server vs Client Components

Durante lo sviluppo, può essere utile identificare visualmente quali componenti sono server e quali client. Ecco un pattern di debugging:

TypeScript
// lib/component-marker.tsx // Client Component marker export function ClientMarker({ children }: { children: React.ReactNode }) { if (process.env.NODE_ENV === 'development') { return ( <div style={{ border: '2px solid blue', position: 'relative' }}> <span style={{ position: 'absolute', top: 0, right: 0, background: 'blue', color: 'white', padding: '2px 5px', fontSize: '10px' }}> CLIENT </span> {children} </div> ) } return <>{children}</> } // Server Component marker export function ServerMarker({ children }: { children: React.ReactNode }) { if (process.env.NODE_ENV === 'development') { return ( <div style={{ border: '2px solid green', position: 'relative' }}> <span style={{ position: 'absolute', top: 0, right: 0, background: 'green', color: 'white', padding: '2px 5px', fontSize: '10px' }}> SERVER </span> {children} </div> ) } return <>{children}</> }

Conclusione: il futuro è ibrido

React Server Components nel 2025 rappresentano il paradigma definitivo per costruire applicazioni web performanti e scalabili. La chiave del successo è capire quando usare Server Components (la maggior parte del tempo) e quando usare Client Components (solo quando necessario).

Riassumendo le regole d'oro:

  • Default a Server Component: Ogni componente dovrebbe essere Server Component a meno che non sia necessario client-side
  • Isola l'interattività: Crea piccoli Client Components focalizzati invece di marcare interi alberi come client
  • Data fetching sul server: Usa async/await nei Server Components per fetch diretto senza API layer
  • Usa Edge Runtime: Per applicazioni global-first che necessitano bassa latenza
  • Sfrutta Streaming: Con Suspense per progressive rendering e UX ottimale
  • TypeScript everywhere: Il 2025 è l'anno del type safety end-to-end

Con Next.js 15, React 19 e TypeScript hai tutti gli strumenti per costruire applicazioni che competono con i migliori player del mercato. Il futuro dello sviluppo web è ibrido: server-first per performance, client quando serve interattività.

Qual è la tua esperienza con React Server Components? Hai già migrato progetti a Next.js 15? Condividi i tuoi casi d'uso e dubbi nei commenti!

React Server Components: guida client vs server | Next.js 15 | artCode