{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture","title":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","description":"Análisis técnico de React Server Components, streaming SSR, Suspense boundaries y server actions, con benchmarks del costo real de los client components y patrones de migración prácticos.","language":"es","datePublished":"2026-01-01","dateModified":null,"contentFormat":"text/markdown","content":"Frontend\n\n# React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración\n\n2026-01-018 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/react-server-components-streaming-architecture>)\n\n## Ciclo de Vida de una Solicitud RSC\n\nEsto es lo que pasa de verdad cuando un browser pide una página con RSC:\n\n1. El servidor recibe el request y arranca a renderizar el árbol de componentes\n2. Los Server Components se ejecutan en el servidor, pueden hacer ` await ` de queries a la base, leer archivos, llamar APIs directamente\n3. Cuando el servidor llega a un boundary ` 'use client' `, deja de renderizar ese subárbol y emite una **referencia**, un puntero que dice \"renderizá este client component aquí con estas props serializadas\"\n4. El servidor hace stream del output en un formato especial llamado el **RSC payload**, no HTML, sino una descripción serializable del árbol de componentes\n5. El runtime de React del lado del cliente recibe este payload, hidrata los client components, y engancha todo\n\nLa idea clave: el RSC payload se **streamea**. El servidor no espera a que todos los datos resuelvan antes de mandar el primer byte. Los Suspense boundaries definen los chunks del streaming.\n\n## El Costo de 'use client'\n\nAcá es donde la gente se pierde: ` 'use client' ` no solo significa \"este componente corre en el cliente.\" Significa **este componente Y todo componente que importa** se vuelve parte del bundle del cliente. Es una declaración de boundary, no un toggle por componente.\n\n```jsx\n// Este solo 'use client' tira todo al bundle del cliente\n'use client';\n\nimport { HeavyChartLibrary } from 'heavy-charts'; // 200KB\nimport { DateFormatter } from './utils'; // 2KB\nimport { UserAvatar } from './UserAvatar'; // 5KB + procesamiento de imágenes\n\nexport function Dashboard({ data }) {\n const [filter, setFilter] = useState('all');\n // ...\n}\n```\n\nLa solución es decomposición quirúrgica:\n\n```jsx\n// ServerDashboard.jsx (server component - no necesita directiva)\nimport { HeavyChartLibrary } from 'heavy-charts'; // ¡se queda en el servidor!\nimport { DateFormatter } from './utils'; // ¡se queda en el servidor!\nimport { DashboardFilter } from './DashboardFilter'; // boundary al cliente\n\nexport async function ServerDashboard() {\n const data = await db.metrics.getAll(); // acceso directo a DB\n\n return (\n <div>\n <h1>Dashboard</h1>\n <DashboardFilter /> {/* client component chiquito */}\n {/* El chart pesado se renderiza en el servidor, se manda como HTML */}\n <HeavyChartLibrary data={data} interactive={false} />\n <p>Última actualización: {DateFormatter.relative(data.updatedAt)}</p>\n </div>\n );\n}\n```\n\n```jsx\n// DashboardFilter.jsx\n'use client';\n\nimport { useState } from 'react';\n\nexport function DashboardFilter() {\n const [filter, setFilter] = useState('all');\n return (\n <select value={filter} onChange={(e) => setFilter(e.target.value)}>\n <option value=\"all\">Todos</option>\n <option value=\"active\">Activos</option>\n <option value=\"archived\">Archivados</option>\n </select>\n );\n}\n```\n\nEsa ` HeavyChartLibrary ` nunca llega al cliente. El chart se renderiza en el servidor y el browser recibe HTML pre-renderizado. El único JavaScript que llega al cliente es el dropdown del filtro.\n\nEn una migración de producción real, este patrón recortó el JS client-side del dashboard principal de 340KB a 89KB (gzipped). El First Contentful Paint mejoró 1.2 segundos en una conexión 4G mediana.\n\n## Streaming SSR con Suspense: El Setup Práctico\n\n```jsx\n// app/page.jsx (server component por defecto en Next.js App Router)\nimport { Suspense } from 'react';\n\nexport default async function ProductPage({ params }) {\n // Esto resuelve rápido, es un lookup simple de DB\n const product = await getProduct(params.id);\n\n return (\n <main>\n <h1>{product.name}</h1>\n <p>{product.description}</p>\n <price>{product.price}</price>\n\n {/* Los reviews pueden ser lentos, no bloqueemos la página */}\n <Suspense fallback={<ReviewsSkeleton />}>\n <ProductReviews productId={params.id} />\n </Suspense>\n\n {/* Las recomendaciones son todavía más lentas, inferencia ML */}\n <Suspense fallback={<RecommendationsSkeleton />}>\n <RecommendationsPanel productId={params.id} />\n </Suspense>\n </main>\n );\n}\n\nasync function ProductReviews({ productId }) {\n const reviews = await getReviews(productId); // puede tomar 800ms\n\n return (\n <section>\n <h2>Reviews ({reviews.length})</h2>\n {reviews.map(r => (\n <ReviewCard key={r.id} review={r} />\n ))}\n </section>\n );\n}\n```\n\nLo que ve el usuario:\n\n1. **Instantáneo**: Nombre del producto, descripción, precio + placeholders skeleton\n2. **~800ms**: Los reviews aparecen, reemplazando el skeleton\n3. **~1500ms**: El panel de recomendaciones se llena\n\nEl browser arranca a renderizar ni bien llega el primer chunk. Nada de pantalla blanca esperando al API call más lento.\n\n## Server Actions: RPC Integrado para React\n\nLos Server Actions son funciones que corren en el servidor pero se pueden llamar desde client components. Compilan a POST requests con referencias encriptadas.\n\n```jsx\n// actions.js\n'use server';\n\nimport { revalidatePath } from 'next/cache';\nimport { redirect } from 'next/navigation';\n\nexport async function createComment(formData) {\n const content = formData.get('content');\n const postId = formData.get('postId');\n\n // Validar en el servidor, nunca confíes en el cliente\n if (!content || content.length > 5000) {\n return { error: 'El comentario debe tener entre 1 y 5000 caracteres' };\n }\n\n const user = await getCurrentUser();\n if (!user) {\n redirect('/login');\n }\n\n await db.comments.create({\n content,\n postId,\n authorId: user.id,\n createdAt: new Date(),\n });\n\n revalidatePath(`/posts/${postId}`);\n return { success: true };\n}\n```\n\n```jsx\n// CommentForm.jsx\n'use client';\n\nimport { useActionState } from 'react';\nimport { createComment } from './actions';\n\nexport function CommentForm({ postId }) {\n const [state, formAction, isPending] = useActionState(createComment, null);\n\n return (\n <form action={formAction}>\n <input type=\"hidden\" name=\"postId\" value={postId} />\n <textarea\n name=\"content\"\n placeholder=\"Escribir un comentario...\"\n disabled={isPending}\n />\n <button type=\"submit\" disabled={isPending}>\n {isPending ? 'Publicando...' : 'Publicar'}\n </button>\n {state?.error && <p className=\"error\">{state.error}</p>}\n </form>\n );\n}\n```\n\nLo hermoso: este form funciona sin JavaScript. El ` <form action={formAction}> ` hace progressive enhancement, sin JS, submite como un POST normal. Con JS, React lo intercepta, manda el request en background, y actualiza la UI optimistamente.\n\nTres forms fueron enviados a producción con este patrón y la historia de progressive enhancement es verdad. En conexiones lentas donde el JS todavía no cargó, los usuarios pueden submitear igual. No es un beneficio teórico, los session replays de usuarios del interior con conexiones 2G lo confirman.\n\n## Patrones de Data Fetching en el Servidor\n\nEn el modelo RSC, ` useEffect ` para data fetching y ` react-query ` para cargas iniciales ya no son necesarios. Los datos se obtienen a nivel de componente:\n\n```jsx\nasync function UserProfile({ userId }) {\n const [user, posts, stats] = await Promise.all([\n db.users.findById(userId),\n db.posts.findByAuthor(userId, { limit: 10 }),\n analytics.getUserStats(userId),\n ]);\n\n return (\n <div>\n <h1>{user.name}</h1>\n <StatsBanner stats={stats} />\n <PostList posts={posts} />\n <Suspense fallback={<ActivitySkeleton />}>\n <ActivityFeed userId={userId} />\n </Suspense>\n </div>\n );\n}\n```\n\nSin loading states para el contenido principal. Sin waterfall requests. Sin el boilerplate de ` useEffect ` + ` useState ` + ` isLoading `. El componente es una función async que fetchea sus datos y renderiza.\n\nEl ` Promise.all ` es importante, sin él, las tres queries corren secuencialmente. Este error aparece constantemente en code reviews.\n\n## Estrategia de Migración: El Enfoque Strangler Fig\n\nNo se va a reescribir tu app. Vas a mover gradualmente componentes al servidor:\n\n**Paso 1**: Identificar tus dependencias client-side más pesadas. Ejecutar ` npx @next/bundle-analyzer ` y ordená por tamaño.\n\n**Paso 2**: Para cada componente pesado, preguntarse: \"¿Esto necesita interactividad?\" Si está renderizando contenido estático derivado de datos, es candidato a server component.\n\n**Paso 3**: Extraé los bits interactivos en client components mínimos.\n\n```jsx\n// Antes: el componente entero es client-side\n'use client';\nimport { marked } from 'marked'; // 35KB\nimport DOMPurify from 'dompurify'; // 20KB\n\nexport function Article({ markdown }) {\n const [fontSize, setFontSize] = useState(16);\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <div style={{ fontSize }}>\n <FontSizeControl value={fontSize} onChange={setFontSize} />\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n```\n\n```jsx\n// Después: procesamiento pesado en servidor, wrapper interactivo mínimo\n// ArticleContent.jsx (server component)\nimport { marked } from 'marked'; // nunca llega al cliente\nimport DOMPurify from 'dompurify'; // nunca llega al cliente\nimport { FontSizeWrapper } from './FontSizeWrapper';\n\nexport function ArticleContent({ markdown }) {\n const html = DOMPurify.sanitize(marked.parse(markdown));\n return (\n <FontSizeWrapper>\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </FontSizeWrapper>\n );\n}\n\n// FontSizeWrapper.jsx (client component, 0.5KB)\n'use client';\nimport { useState } from 'react';\n\nexport function FontSizeWrapper({ children }) {\n const [fontSize, setFontSize] = useState(16);\n return (\n <div style={{ fontSize }}>\n <button onClick={() => setFontSize(f => f - 2)}>A-</button>\n <button onClick={() => setFontSize(f => f + 2)}>A+</button>\n {children}\n </div>\n );\n}\n```\n\n70KB de librerías eliminadas del bundle del cliente.\n\n## Métricas de Performance Medidas\n\nEn una página de producto e-commerce real (Next.js 15, App Router, deployed en [Vercel](<https://vercel.com/>)):\n\n| Métrica | Pages Router (CSR) | App Router (RSC + Streaming) |\n| --- | --- | --- |\n| FCP | 2.1s | 0.8s |\n| LCP | 3.4s | 1.2s |\n| TTI | 4.2s | 1.8s |\n| Client JS (gzip) | 287KB | 112KB |\n| TTFB | 180ms | 210ms |\n\nEl TTFB es ligeramente *peor* con RSC porque el servidor hace más trabajo antes de mandar el primer byte. Pero todo lo demás es dramáticamente mejor porque el cliente recibe HTML pre-renderizado y menos JavaScript.\n\n## Los Filos Cortantes\n\n**Serialization boundary**: Las props pasadas de server a client components tienen que ser serializables. Nada de funciones, ni clases, ni objetos ` Date ` (utilizar ISO strings), ni ` Map `/` Set `.\n\n**Librerías de terceros**: La mayoría de las librerías React asumen un entorno client. Si se importa una librería que accede a ` window ` o ` document ` en un server component, se va a tener un error de build.\n\n**Debugging**: El formato del RSC payload no es legible por humanos. Cuando algo sale mal en el handoff server-to-client, los mensajes de error son crípticos.\n\n## Cuándo NO Usar RSC\n\nNo todo debería ser un server component. UIs altamente interactivas, builders drag-and-drop, editores colaborativos real-time, herramientas basadas en canvas, siguen siendo mejores como apps client-side. RSC brilla para páginas pesadas en contenido con islas de interactividad, que es francamente la mayor parte de la web.\n\nEl modelo mental clave: si se están renderizando datos, usar un server component. Si se está manejando estado, usar un client component. El boundary entre ellos es donde la arquitectura resulta interesante, y acertar ese boundary es lo que separa una app RSC rápida de una lenta que simplemente está haciendo SSR con pasos extra.\n\n## Preguntas frecuentes\n\n### ¿Los React Server Components reemplazan SSR?\n\nNo, lo complementan. SSR renderiza el HTML inicial. RSC determina qué componentes corren en el servidor vs. el cliente y habilita streaming. Típicamente se utiliza los dos juntos.\n\n### ¿Puedo usar RSC sin Next.js?\n\nTécnicamente sí, el equipo de React publicó una spec de bundler. Pero en la práctica, Next.js y Waku son las únicas implementaciones production-ready. Hacer tu propia integración RSC es un proyecto de meses.\n\n### ¿Cuánto ahorro real hay en bundle size?\n\nDepende de tu árbol de componentes. En una migración representativa, mover data-fetching y renderizado de markdown a server components cortó el JS del cliente un 38%. Las ganancias vienen de dependencias pesadas que nunca necesitan llegar al browser.\n\n## Tips\n\n1. **Tip:** No le metas ` 'use client' ` a un componente solo porque usa ` useState `, levantá el estado a un wrapper client chiquito y dejar el renderizado pesado en el servidor.\n2. **Tip:** Colocar tus ` <Suspense> ` boundaries en los bordes de data-fetching, no alrededor de cada componente. Cada boundary agrega un chunk al stream y demasiados causan layout thrash.\n3. **Tip:** Los Server Actions son POST requests por debajo. Los es posible llamar desde ` fetch() ` fuera de React, útil para service workers y background sync.\n4. **Tip:** Utilizar ` React.cache() ` para deduplicar fetches de datos dentro de un solo render pass. Tiene scope de request, no es persistente, no lo confundas con ` unstable_cache `.\n\n- react\n- rsc\n- server-components\n- streaming\n- ssr\n- suspense\n- next.js\n- frontend-architecture\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#title"},{"level":2,"text":"Ciclo de Vida de una Solicitud RSC","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#ciclo-de-vida-de-una-solicitud-rsc"},{"level":2,"text":"El Costo de 'use client'","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#el-costo-de-use-client"},{"level":2,"text":"Streaming SSR con Suspense: El Setup Práctico","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#streaming-ssr-con-suspense-el-setup-prctico"},{"level":2,"text":"Server Actions: RPC Integrado para React","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#server-actions-rpc-integrado-para-react"},{"level":2,"text":"Patrones de Data Fetching en el Servidor","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#patrones-de-data-fetching-en-el-servidor"},{"level":2,"text":"Estrategia de Migración: El Enfoque Strangler Fig","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#estrategia-de-migracin-el-enfoque-strangler-fig"},{"level":2,"text":"Métricas de Performance Medidas","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#mtricas-de-performance-medidas"},{"level":2,"text":"Los Filos Cortantes","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#los-filos-cortantes"},{"level":2,"text":"Cuándo NO Usar RSC","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#cundo-no-usar-rsc"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#questions"},{"level":3,"text":"¿Los React Server Components reemplazan SSR?","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-1"},{"level":3,"text":"¿Puedo usar RSC sin Next.js?","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-2"},{"level":3,"text":"¿Cuánto ahorro real hay en bundle size?","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#article-tips"}],"sections":[{"heading":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","level":1,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture","content":"Frontend"},{"heading":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","level":1,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#title","content":"2026-01-018 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/react-server-components-streaming-architecture>)"},{"heading":"Ciclo de Vida de una Solicitud RSC","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#ciclo-de-vida-de-una-solicitud-rsc","content":"Esto es lo que pasa de verdad cuando un browser pide una página con RSC:\n\n1. El servidor recibe el request y arranca a renderizar el árbol de componentes\n2. Los Server Components se ejecutan en el servidor, pueden hacer ` await ` de queries a la base, leer archivos, llamar APIs directamente\n3. Cuando el servidor llega a un boundary ` 'use client' `, deja de renderizar ese subárbol y emite una **referencia**, un puntero que dice \"renderizá este client component aquí con estas props serializadas\"\n4. El servidor hace stream del output en un formato especial llamado el **RSC payload**, no HTML, sino una descripción serializable del árbol de componentes\n5. El runtime de React del lado del cliente recibe este payload, hidrata los client components, y engancha todo\n\nLa idea clave: el RSC payload se **streamea**. El servidor no espera a que todos los datos resuelvan antes de mandar el primer byte. Los Suspense boundaries definen los chunks del streaming."},{"heading":"El Costo de 'use client'","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#el-costo-de-use-client","content":"Acá es donde la gente se pierde: ` 'use client' ` no solo significa \"este componente corre en el cliente.\" Significa **este componente Y todo componente que importa** se vuelve parte del bundle del cliente. Es una declaración de boundary, no un toggle por componente.\n\n```jsx\n// Este solo 'use client' tira todo al bundle del cliente\n'use client';\n\nimport { HeavyChartLibrary } from 'heavy-charts'; // 200KB\nimport { DateFormatter } from './utils'; // 2KB\nimport { UserAvatar } from './UserAvatar'; // 5KB + procesamiento de imágenes\n\nexport function Dashboard({ data }) {\n const [filter, setFilter] = useState('all');\n // ...\n}\n```\n\nLa solución es decomposición quirúrgica:\n\n```jsx\n// ServerDashboard.jsx (server component - no necesita directiva)\nimport { HeavyChartLibrary } from 'heavy-charts'; // ¡se queda en el servidor!\nimport { DateFormatter } from './utils'; // ¡se queda en el servidor!\nimport { DashboardFilter } from './DashboardFilter'; // boundary al cliente\n\nexport async function ServerDashboard() {\n const data = await db.metrics.getAll(); // acceso directo a DB\n\n return (\n <div>\n <h1>Dashboard</h1>\n <DashboardFilter /> {/* client component chiquito */}\n {/* El chart pesado se renderiza en el servidor, se manda como HTML */}\n <HeavyChartLibrary data={data} interactive={false} />\n <p>Última actualización: {DateFormatter.relative(data.updatedAt)}</p>\n </div>\n );\n}\n```\n\n```jsx\n// DashboardFilter.jsx\n'use client';\n\nimport { useState } from 'react';\n\nexport function DashboardFilter() {\n const [filter, setFilter] = useState('all');\n return (\n <select value={filter} onChange={(e) => setFilter(e.target.value)}>\n <option value=\"all\">Todos</option>\n <option value=\"active\">Activos</option>\n <option value=\"archived\">Archivados</option>\n </select>\n );\n}\n```\n\nEsa ` HeavyChartLibrary ` nunca llega al cliente. El chart se renderiza en el servidor y el browser recibe HTML pre-renderizado. El único JavaScript que llega al cliente es el dropdown del filtro.\n\nEn una migración de producción real, este patrón recortó el JS client-side del dashboard principal de 340KB a 89KB (gzipped). El First Contentful Paint mejoró 1.2 segundos en una conexión 4G mediana."},{"heading":"Streaming SSR con Suspense: El Setup Práctico","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#streaming-ssr-con-suspense-el-setup-prctico","content":"```jsx\n// app/page.jsx (server component por defecto en Next.js App Router)\nimport { Suspense } from 'react';\n\nexport default async function ProductPage({ params }) {\n // Esto resuelve rápido, es un lookup simple de DB\n const product = await getProduct(params.id);\n\n return (\n <main>\n <h1>{product.name}</h1>\n <p>{product.description}</p>\n <price>{product.price}</price>\n\n {/* Los reviews pueden ser lentos, no bloqueemos la página */}\n <Suspense fallback={<ReviewsSkeleton />}>\n <ProductReviews productId={params.id} />\n </Suspense>\n\n {/* Las recomendaciones son todavía más lentas, inferencia ML */}\n <Suspense fallback={<RecommendationsSkeleton />}>\n <RecommendationsPanel productId={params.id} />\n </Suspense>\n </main>\n );\n}\n\nasync function ProductReviews({ productId }) {\n const reviews = await getReviews(productId); // puede tomar 800ms\n\n return (\n <section>\n <h2>Reviews ({reviews.length})</h2>\n {reviews.map(r => (\n <ReviewCard key={r.id} review={r} />\n ))}\n </section>\n );\n}\n```\n\nLo que ve el usuario:\n\n1. **Instantáneo**: Nombre del producto, descripción, precio + placeholders skeleton\n2. **~800ms**: Los reviews aparecen, reemplazando el skeleton\n3. **~1500ms**: El panel de recomendaciones se llena\n\nEl browser arranca a renderizar ni bien llega el primer chunk. Nada de pantalla blanca esperando al API call más lento."},{"heading":"Server Actions: RPC Integrado para React","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#server-actions-rpc-integrado-para-react","content":"Los Server Actions son funciones que corren en el servidor pero se pueden llamar desde client components. Compilan a POST requests con referencias encriptadas.\n\n```jsx\n// actions.js\n'use server';\n\nimport { revalidatePath } from 'next/cache';\nimport { redirect } from 'next/navigation';\n\nexport async function createComment(formData) {\n const content = formData.get('content');\n const postId = formData.get('postId');\n\n // Validar en el servidor, nunca confíes en el cliente\n if (!content || content.length > 5000) {\n return { error: 'El comentario debe tener entre 1 y 5000 caracteres' };\n }\n\n const user = await getCurrentUser();\n if (!user) {\n redirect('/login');\n }\n\n await db.comments.create({\n content,\n postId,\n authorId: user.id,\n createdAt: new Date(),\n });\n\n revalidatePath(`/posts/${postId}`);\n return { success: true };\n}\n```\n\n```jsx\n// CommentForm.jsx\n'use client';\n\nimport { useActionState } from 'react';\nimport { createComment } from './actions';\n\nexport function CommentForm({ postId }) {\n const [state, formAction, isPending] = useActionState(createComment, null);\n\n return (\n <form action={formAction}>\n <input type=\"hidden\" name=\"postId\" value={postId} />\n <textarea\n name=\"content\"\n placeholder=\"Escribir un comentario...\"\n disabled={isPending}\n />\n <button type=\"submit\" disabled={isPending}>\n {isPending ? 'Publicando...' : 'Publicar'}\n </button>\n {state?.error && <p className=\"error\">{state.error}</p>}\n </form>\n );\n}\n```\n\nLo hermoso: este form funciona sin JavaScript. El ` <form action={formAction}> ` hace progressive enhancement, sin JS, submite como un POST normal. Con JS, React lo intercepta, manda el request en background, y actualiza la UI optimistamente.\n\nTres forms fueron enviados a producción con este patrón y la historia de progressive enhancement es verdad. En conexiones lentas donde el JS todavía no cargó, los usuarios pueden submitear igual. No es un beneficio teórico, los session replays de usuarios del interior con conexiones 2G lo confirman."},{"heading":"Patrones de Data Fetching en el Servidor","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#patrones-de-data-fetching-en-el-servidor","content":"En el modelo RSC, ` useEffect ` para data fetching y ` react-query ` para cargas iniciales ya no son necesarios. Los datos se obtienen a nivel de componente:\n\n```jsx\nasync function UserProfile({ userId }) {\n const [user, posts, stats] = await Promise.all([\n db.users.findById(userId),\n db.posts.findByAuthor(userId, { limit: 10 }),\n analytics.getUserStats(userId),\n ]);\n\n return (\n <div>\n <h1>{user.name}</h1>\n <StatsBanner stats={stats} />\n <PostList posts={posts} />\n <Suspense fallback={<ActivitySkeleton />}>\n <ActivityFeed userId={userId} />\n </Suspense>\n </div>\n );\n}\n```\n\nSin loading states para el contenido principal. Sin waterfall requests. Sin el boilerplate de ` useEffect ` + ` useState ` + ` isLoading `. El componente es una función async que fetchea sus datos y renderiza.\n\nEl ` Promise.all ` es importante, sin él, las tres queries corren secuencialmente. Este error aparece constantemente en code reviews."},{"heading":"Estrategia de Migración: El Enfoque Strangler Fig","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#estrategia-de-migracin-el-enfoque-strangler-fig","content":"No se va a reescribir tu app. Vas a mover gradualmente componentes al servidor:\n\n**Paso 1**: Identificar tus dependencias client-side más pesadas. Ejecutar ` npx @next/bundle-analyzer ` y ordená por tamaño.\n\n**Paso 2**: Para cada componente pesado, preguntarse: \"¿Esto necesita interactividad?\" Si está renderizando contenido estático derivado de datos, es candidato a server component.\n\n**Paso 3**: Extraé los bits interactivos en client components mínimos.\n\n```jsx\n// Antes: el componente entero es client-side\n'use client';\nimport { marked } from 'marked'; // 35KB\nimport DOMPurify from 'dompurify'; // 20KB\n\nexport function Article({ markdown }) {\n const [fontSize, setFontSize] = useState(16);\n const html = DOMPurify.sanitize(marked.parse(markdown));\n\n return (\n <div style={{ fontSize }}>\n <FontSizeControl value={fontSize} onChange={setFontSize} />\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </div>\n );\n}\n```\n\n```jsx\n// Después: procesamiento pesado en servidor, wrapper interactivo mínimo\n// ArticleContent.jsx (server component)\nimport { marked } from 'marked'; // nunca llega al cliente\nimport DOMPurify from 'dompurify'; // nunca llega al cliente\nimport { FontSizeWrapper } from './FontSizeWrapper';\n\nexport function ArticleContent({ markdown }) {\n const html = DOMPurify.sanitize(marked.parse(markdown));\n return (\n <FontSizeWrapper>\n <div dangerouslySetInnerHTML={{ __html: html }} />\n </FontSizeWrapper>\n );\n}\n\n// FontSizeWrapper.jsx (client component, 0.5KB)\n'use client';\nimport { useState } from 'react';\n\nexport function FontSizeWrapper({ children }) {\n const [fontSize, setFontSize] = useState(16);\n return (\n <div style={{ fontSize }}>\n <button onClick={() => setFontSize(f => f - 2)}>A-</button>\n <button onClick={() => setFontSize(f => f + 2)}>A+</button>\n {children}\n </div>\n );\n}\n```\n\n70KB de librerías eliminadas del bundle del cliente."},{"heading":"Métricas de Performance Medidas","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#mtricas-de-performance-medidas","content":"En una página de producto e-commerce real (Next.js 15, App Router, deployed en [Vercel](<https://vercel.com/>)):\n\n| Métrica | Pages Router (CSR) | App Router (RSC + Streaming) |\n| --- | --- | --- |\n| FCP | 2.1s | 0.8s |\n| LCP | 3.4s | 1.2s |\n| TTI | 4.2s | 1.8s |\n| Client JS (gzip) | 287KB | 112KB |\n| TTFB | 180ms | 210ms |\n\nEl TTFB es ligeramente *peor* con RSC porque el servidor hace más trabajo antes de mandar el primer byte. Pero todo lo demás es dramáticamente mejor porque el cliente recibe HTML pre-renderizado y menos JavaScript."},{"heading":"Los Filos Cortantes","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#los-filos-cortantes","content":"**Serialization boundary**: Las props pasadas de server a client components tienen que ser serializables. Nada de funciones, ni clases, ni objetos ` Date ` (utilizar ISO strings), ni ` Map `/` Set `.\n\n**Librerías de terceros**: La mayoría de las librerías React asumen un entorno client. Si se importa una librería que accede a ` window ` o ` document ` en un server component, se va a tener un error de build.\n\n**Debugging**: El formato del RSC payload no es legible por humanos. Cuando algo sale mal en el handoff server-to-client, los mensajes de error son crípticos."},{"heading":"Cuándo NO Usar RSC","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#cundo-no-usar-rsc","content":"No todo debería ser un server component. UIs altamente interactivas, builders drag-and-drop, editores colaborativos real-time, herramientas basadas en canvas, siguen siendo mejores como apps client-side. RSC brilla para páginas pesadas en contenido con islas de interactividad, que es francamente la mayor parte de la web.\n\nEl modelo mental clave: si se están renderizando datos, usar un server component. Si se está manejando estado, usar un client component. El boundary entre ellos es donde la arquitectura resulta interesante, y acertar ese boundary es lo que separa una app RSC rápida de una lenta que simplemente está haciendo SSR con pasos extra."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#questions","content":""},{"heading":"¿Los React Server Components reemplazan SSR?","level":3,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-1","content":"No, lo complementan. SSR renderiza el HTML inicial. RSC determina qué componentes corren en el servidor vs. el cliente y habilita streaming. Típicamente se utiliza los dos juntos."},{"heading":"¿Puedo usar RSC sin Next.js?","level":3,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-2","content":"Técnicamente sí, el equipo de React publicó una spec de bundler. Pero en la práctica, Next.js y Waku son las únicas implementaciones production-ready. Hacer tu propia integración RSC es un proyecto de meses."},{"heading":"¿Cuánto ahorro real hay en bundle size?","level":3,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#question-3","content":"Depende de tu árbol de componentes. En una migración representativa, mover data-fetching y renderizado de markdown a server components cortó el JS del cliente un 38%. Las ganancias vienen de dependencias pesadas que nunca necesitan llegar al browser."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#article-tips","content":"1. **Tip:** No le metas ` 'use client' ` a un componente solo porque usa ` useState `, levantá el estado a un wrapper client chiquito y dejar el renderizado pesado en el servidor.\n2. **Tip:** Colocar tus ` <Suspense> ` boundaries en los bordes de data-fetching, no alrededor de cada componente. Cada boundary agrega un chunk al stream y demasiados causan layout thrash.\n3. **Tip:** Los Server Actions son POST requests por debajo. Los es posible llamar desde ` fetch() ` fuera de React, útil para service workers y background sync.\n4. **Tip:** Utilizar ` React.cache() ` para deduplicar fetches de datos dentro de un solo render pass. Tiene scope de request, no es persistente, no lo confundas con ` unstable_cache `.\n\n- react\n- rsc\n- server-components\n- streaming\n- ssr\n- suspense\n- next.js\n- frontend-architecture\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture"},"headline":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","description":"Análisis técnico de React Server Components, streaming SSR, Suspense boundaries y server actions, con benchmarks del costo real de los client components y patrones de migración prácticos.","datePublished":"2026-01-01","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Frontend","keywords":"react, rsc, server-components, streaming, ssr, suspense, next.js, frontend-architecture","wordCount":1532,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/react-server-components-streaming-architecture"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"React Server Components: Arquitectura de Streaming, Costos de Rendimiento y Patrones de Migración","item":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Los React Server Components reemplazan SSR?","acceptedAnswer":{"@type":"Answer","text":"No, lo complementan. SSR renderiza el HTML inicial. RSC determina qué componentes corren en el servidor vs. el cliente y habilita streaming. Típicamente se utiliza los dos juntos."}},{"@type":"Question","name":"¿Puedo usar RSC sin Next.js?","acceptedAnswer":{"@type":"Answer","text":"Técnicamente sí, el equipo de React publicó una spec de bundler. Pero en la práctica, Next.js y Waku son las únicas implementaciones production-ready. Hacer tu propia integración RSC es un proyecto de meses."}},{"@type":"Question","name":"¿Cuánto ahorro real hay en bundle size?","acceptedAnswer":{"@type":"Answer","text":"Depende de tu árbol de componentes. En una migración representativa, mover data-fetching y renderizado de markdown a server components cortó el JS del cliente un 38%. Las ganancias vienen de dependencias pesadas que nunca necesitan llegar al browser."}}]}]}],"contentHash":"87fa5088047e4b2f1285bee836a546709f0eebcf24c5c88cf15577ed85f3b511","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/react-server-components-streaming-architecture","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67723,"contentBytes":13449}}
