rulocode
Blog

React Server Components en producción: lo que realmente aprendí

10 de enero de 2026· 4 min de lectura
Cuando Next.js 13 introdujo el App Router con React Server Components (RSC), yo era escéptico. ¿Otro cambio de paradigma? ¿Más complejidad? Luego migré una plataforma grande de e-commerce a esta tecnología. 18 meses después, estoy convencido, pero el camino me dejó lecciones que no están en la documentación. Aquí va la realidad sin filtros de usar RSC en producción.
  • Cero JavaScript para los server components
  • Bundles más pequeños
  • Data fetching más simple
  • Mejor performance
  • 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
El mayor cambio mental: no estás solo construyendo componentes, estás decidiendo dónde vive la frontera entre cliente y servidor.
// 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>
  )
}
// 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} />
    </>
  )
}
La clave: empuja la frontera de 'use client' lo más abajo posible. Mientras más puedas mantener en el servidor, menos JS envías al cliente. Esto me quemó desde el principio. No puedes pasar callbacks de Server Components a Client Components.
// 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} />
}
// 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>
}
// 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')
}
La frontera de 'use client' no significa que todo lo que esté debajo se renderiza en el cliente. Todavía puedes pasar Server Components como children.
// 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>
  )
}
Por qué importa: el contenido de las reseñas se renderiza en el servidor (nada de JS para ese markup), pero la interactividad del carrusel es del lado del cliente. Lo mejor de ambos mundos. Con streaming puedes mostrar partes de la página antes de que otras terminen de cargar. Esto cambia cómo piensas los estados de carga.
function ProductPage() {
  const { data, isLoading } = useQuery(['product', id])
  
  if (isLoading) return <FullPageSpinner />
  
  return <ProductContent data={data} />
}
// 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>
  )
}
Impacto real: nuestro tiempo de carga percibido bajó un 40% porque los usuarios ven contenido más rápido, aunque el tiempo total de data fetching sea similar. RSC cambia cómo funciona el caching. Por defecto, Next.js cachea de forma agresiva.
// This might serve stale data!
async function ProductPage({ params }) {
  const product = await getProduct(params.id)
  return <ProductInfo product={product} />
}
// 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 } })
// 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} />
}
Con componentes async, los errores se propagan de manera distinta.
// 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>
  )
}
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>
  )
}
Después de migrar nuestra plataforma de e-commerce:
MétricaAntes (Pages Router)Después (App Router + RSC)
JS inicial340 KB180 KB
LCP3.2s2.1s
TTI4.8s2.8s
Lighthouse7894
La complejidad del código de hecho bajó una vez que internalizamos los patrones:
  • Se acabó el useEffect para data fetching
  • Se acabó el boilerplate para manejar estados de carga
  • Separación de responsabilidades más clara
RSC no siempre es la respuesta:
  1. UIs altamente interactivas (drag-and-drop, formularios complejos): mantenlas del lado del cliente
  2. Features en tiempo real: las UIs basadas en WebSockets necesitan client components
  3. APIs del navegador: cualquier cosa que use window, document, etc.
  4. Librerías de terceros que requieren cliente: algunas librerías de UI todavía no soportan RSC
  1. Empuja 'use client' hacia abajo: mientras más baja la frontera, menos JS envías
  2. Las funciones no cruzan la frontera: usa Server Actions o define los handlers en client components
  3. La composición sigue funcionando: pasa Server Components como children a Client Components
  4. El streaming mejora la performance percibida: usa Suspense de forma estratégica
  5. Cachea con intención: entiende el comportamiento por defecto y sobreescríbelo cuando haga falta
  6. 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 →
Newsletter

Una automatización real, explicada, cada dos semanas.

Lo que estoy montando con IA para mi trabajo y el de mis alumnos: el flujo, las herramientas de clics y las horas que devuelve. Nada de resúmenes de noticias.
Sin spam. Te das de baja con un clic. Si ya estás en la Semana 0, ya la recibes.
Rulo, el robot de rulocode, en la estación final de su mundo