React Server Components en producción: lo que realmente aprendí
La promesa vs. la realidad
La promesa
- Cero JavaScript para los server components
- Bundles más pequeños
- Data fetching más simple
- Mejor performance
La realidad
- En su mayoría cierto, pero con matices
- El cambio de modelo mental es significativo
- Algunos patrones que has usado por años dejan de funcionar
- El debugging es diferente
Lección #1: Piensa en fronteras, no en componentes
Antes de RSC (todo es cliente)
// Every component is interactive by default
function ProductPage({ productId }) {
const [product, setProduct] = useState(null)
const [quantity, setQuantity] = useState(1)
useEffect(() => {
fetchProduct(productId).then(setProduct)
}, [productId])
return (
<div>
<ProductInfo product={product} />
<QuantitySelector value={quantity} onChange={setQuantity} />
<AddToCartButton product={product} quantity={quantity} />
</div>
)
}Con RSC (servidor por defecto)
// app/product/[id]/page.tsx - Server Component
async function ProductPage({ params }) {
// Data fetching happens on server, no useEffect
const product = await getProduct(params.id)
return (
<div>
{/* Server Component - no JS shipped */}
<ProductInfo product={product} />
{/* Client boundary for interactive parts */}
<InteractiveSection product={product} />
</div>
)
}// components/InteractiveSection.tsx
'use client' // This is the boundary
function InteractiveSection({ product }) {
const [quantity, setQuantity] = useState(1)
return (
<>
<QuantitySelector value={quantity} onChange={setQuantity} />
<AddToCartButton product={product} quantity={quantity} />
</>
)
}'use client' lo más abajo posible. Mientras más puedas mantener en el servidor, menos JS envías al cliente.
Lección #2: No puedes pasar funciones a través de la frontera
❌ Esto no funciona
// Server Component
async function ProductPage() {
const product = await getProduct(id)
const handleAddToCart = async () => {
// Can't pass this to a Client Component!
await addToCart(product.id)
}
return <AddToCartButton onClick={handleAddToCart} />
}✅ Esto sí funciona
// Server Component
async function ProductPage() {
const product = await getProduct(id)
return <AddToCartButton productId={product.id} />
}
// Client Component
'use client'
function AddToCartButton({ productId }) {
const handleClick = async () => {
// Define the action in the client component
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId })
})
}
return <button onClick={handleClick}>Add to Cart</button>
}O usa Server Actions
// Server Component
import { addToCart } from '@/actions/cart'
async function ProductPage() {
const product = await getProduct(id)
return (
<form action={addToCart}>
<input type="hidden" name="productId" value={product.id} />
<SubmitButton />
</form>
)
}
// actions/cart.ts
'use server'
export async function addToCart(formData: FormData) {
const productId = formData.get('productId')
// This runs on the server
await db.cart.add({ productId, userId: getCurrentUser() })
revalidatePath('/cart')
}Lección #3: La composición es tu aliada
'use client' no significa que todo lo que esté debajo se renderiza en el cliente. Todavía puedes pasar Server Components como children.
El patrón
// Server Component
async function ProductReviews({ productId }) {
const reviews = await getReviews(productId) // Server data fetching
return (
// Client Component wrapper for interactivity
<ReviewsCarousel>
{/* Server-rendered children */}
{reviews.map(review => (
<ReviewCard key={review.id} review={review} />
))}
</ReviewsCarousel>
)
}// Client Component - handles carousel logic
'use client'
function ReviewsCarousel({ children }) {
const [currentSlide, setCurrentSlide] = useState(0)
return (
<div className="carousel">
{/* children are already server-rendered */}
{children}
<CarouselControls onChange={setCurrentSlide} />
</div>
)
}Lección #4: El streaming cambia el diseño de UX
Antes: un solo estado de carga gigante
function ProductPage() {
const { data, isLoading } = useQuery(['product', id])
if (isLoading) return <FullPageSpinner />
return <ProductContent data={data} />
}Después: carga progresiva
// app/product/[id]/page.tsx
import { Suspense } from 'react'
async function ProductPage({ params }) {
const product = await getProduct(params.id) // Fast query
return (
<div>
{/* Shows immediately */}
<ProductHeader product={product} />
{/* Streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<SimilarProducts productId={params.id} />
</Suspense>
</div>
)
}Lección #5: El cache es crítico (y engañoso)
El problema
// This might serve stale data!
async function ProductPage({ params }) {
const product = await getProduct(params.id)
return <ProductInfo product={product} />
}Entendiendo el comportamiento del cache
// Default: cached indefinitely (during build)
const product = await getProduct(id)
// Force fresh data every request
const product = await getProduct(id, { cache: 'no-store' })
// Revalidate every 60 seconds
const product = await getProduct(id, { next: { revalidate: 60 } })Lo que nos funcionó
// Products: revalidate every minute (prices change)
export const revalidate = 60
async function ProductPage({ params }) {
const product = await getProduct(params.id)
return <ProductInfo product={product} />
}
// Cart: always fresh
async function CartPage() {
const cart = await getCart({ cache: 'no-store' })
return <CartContents cart={cart} />
}
// Categories: cache for longer (rarely change)
async function CategoryPage({ params }) {
const category = await getCategory(params.slug, {
next: { revalidate: 3600 } // 1 hour
})
return <CategoryContent category={category} />
}Lección #6: El manejo de errores es diferente
El patrón
// app/product/[id]/page.tsx
async function ProductPage({ params }) {
const product = await getProduct(params.id)
if (!product) {
notFound() // Triggers not-found.tsx
}
return <ProductContent product={product} />
}
// app/product/[id]/error.tsx
'use client' // Error boundaries must be client components
function ProductError({ error, reset }) {
return (
<div>
<h2>Something went wrong loading this product</h2>
<button onClick={reset}>Try again</button>
</div>
)
}Error boundaries granulares
async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<div>
<ProductHeader product={product} />
{/* Isolate errors in non-critical sections */}
<ErrorBoundary fallback={<ReviewsError />}>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
</ErrorBoundary>
</div>
)
}Los resultados
| Métrica | Antes (Pages Router) | Después (App Router + RSC) |
|---|---|---|
| JS inicial | 340 KB | 180 KB |
| LCP | 3.2s | 2.1s |
| TTI | 4.8s | 2.8s |
| Lighthouse | 78 | 94 |
- Se acabó el
useEffectpara data fetching - Se acabó el boilerplate para manejar estados de carga
- Separación de responsabilidades más clara
Cuándo NO usar Server Components
- UIs altamente interactivas (drag-and-drop, formularios complejos): mantenlas del lado del cliente
- Features en tiempo real: las UIs basadas en WebSockets necesitan client components
-
APIs del navegador: cualquier cosa que use
window,document, etc. - Librerías de terceros que requieren cliente: algunas librerías de UI todavía no soportan RSC
Conclusiones clave
-
Empuja
'use client'hacia abajo: mientras más baja la frontera, menos JS envías - Las funciones no cruzan la frontera: usa Server Actions o define los handlers en client components
- La composición sigue funcionando: pasa Server Components como children a Client Components
- El streaming mejora la performance percibida: usa Suspense de forma estratégica
- Cachea con intención: entiende el comportamiento por defecto y sobreescríbelo cuando haga falta
- Los error boundaries deben ser client components: diseña tu manejo de errores con eso en mente
¿Estás considerando migrar al App Router? Yo ya pasé por eso. Hablemos →


