React Server Components: Modelos Mentales, Patrones y Errores Comunes
La guía práctica de React Server Components que desearía haber tenido al empezar. Modelos mentales, patrones reales, el problema de los límites y los errores que cometí para que tú no los cometas.
Pasé los primeros tres meses con React Server Components pensando que los entendía. Había leído los RFCs, visto las charlas de conferencias, construido algunas apps de demostración. Estaba confiadamente equivocado sobre casi todo.
El problema no es que RSC sea complicado. Es que el modelo mental es genuinamente diferente de cualquier cosa que hayamos hecho antes en React, y todos — incluyéndome — intentan meterlo en la caja vieja. "Es como SSR." No lo es. "Es como PHP." Más cercano, pero no. "Son solo componentes que se ejecutan en el servidor." Técnicamente cierto, prácticamente inútil.
Lo que sigue es todo lo que realmente necesitaba saber, escrito de la forma en que desearía que alguien me lo hubiera explicado. No la versión teórica. La versión donde estás mirando un error de serialización a las 11 PM y necesitas entender por qué.
El Modelo Mental Que Realmente Funciona#
Olvida todo lo que sabes sobre el renderizado de React por un momento. Aquí está la nueva imagen.
En React tradicional (lado del cliente), todo tu árbol de componentes se envía al navegador como JavaScript. El navegador lo descarga, lo parsea, lo ejecuta y renderiza el resultado. Cada componente — ya sea un formulario interactivo de 200 líneas o un párrafo de texto estático — pasa por el mismo pipeline.
React Server Components divide esto en dos mundos:
Mundo del Servidor: Los componentes se ejecutan en el servidor durante la solicitud. Pueden acceder a bases de datos, al sistema de archivos, a variables de entorno, a cualquier recurso del backend. Su código JavaScript nunca se envía al navegador. Solo su salida renderizada (el "payload RSC") viaja por el cable.
Mundo del Cliente: Los componentes se ejecutan en el navegador. Manejan interactividad, estado, efectos, APIs del navegador. Estos se envían al navegador como JavaScript, igual que siempre.
La idea clave: Server Components son el predeterminado. Cada componente es un Server Component a menos que explícitamente optes por el lado del cliente con la directiva "use client". Esto es lo opuesto a cómo funcionaba React antes, donde todo era implícitamente del lado del cliente.
El Límite#
Aquí es donde la gente se confunde. Cuando pones "use client" en la parte superior de un archivo, estás dibujando un límite. Todo en ese archivo, y todo lo que importe, se convierte en código del lado del cliente. Se envía al navegador. Se ejecuta en el navegador.
Piensa en "use client" como un punto de entrada al Mundo del Cliente. Una vez que cruzas, no puedes volver. Un Client Component no puede importar un Server Component — si lo intentas, Next.js intentará tratar ese Server Component como código del cliente, y fallará si usa características solo del servidor como async/await a nivel de componente o acceso directo a base de datos.
Pero aquí está la sutileza que me tomó semanas entender: un Client Component puede renderizar Server Components si se pasan como children o props. Los componentes en sí están pre-renderizados en el servidor, y su salida (no su código) se pasa a través del Client Component. Más sobre este patrón en breve.
Qué Puede y Qué No Puede Hacer Cada Uno#
Server Components pueden:
- Hacer
awaitdirectamente (el componente en sí puede serasync) - Acceder a bases de datos, sistemas de archivos, secrets
- Importar bibliotecas grandes sin impacto en el bundle del cliente
- Renderizar otros Server Components
Server Components no pueden:
- Usar
useState,useEffect,useReducer,useContext - Adjuntar manejadores de eventos (
onClick,onChange, etc.) - Usar APIs del navegador (
window,document,localStorage) - Usar hooks personalizados que dependan del estado o los efectos
Client Components pueden:
- Todo lo que siempre pudieron hacer los componentes de React
- Gestionar estado, ejecutar efectos, manejar eventos
Client Components no pueden:
- Ser
async(sinawaita nivel de componente) - Importar Server Components directamente (pero pueden renderizarlos como children)
Visualizando el Límite#
Servidor Cliente
┌─────────────────────┐ ┌───────────────────────┐
│ Layout (Server) │ │ │
│ ├── Header (Server)│ │ │
│ ├── Sidebar ───────┼───►│ Sidebar (Client) │
│ │ (data fetching)│ │ (toggle, animation) │
│ ├── Content │ │ │
│ │ ├── Title │ │ │
│ │ ├── Body │ │ │
│ │ └── Actions ───┼───►│ Actions (Client) │
│ │ (buttons) │ │ (onClick handlers) │
│ └── Footer (Server)│ │ │
└─────────────────────┘ └───────────────────────┘
Cada flecha ───► es un cruce de límite. Los datos que cruzan deben ser serializables (JSON-stringificables). No funciones, no clases, no promesas.
Un Ejemplo Concreto#
// app/products/page.tsx — Server Component (predeterminado)
import { db } from "@/lib/database";
import { ProductList } from "./ProductList";
export default async function ProductsPage() {
// Esto se ejecuta en el servidor — acceso directo a base de datos, sin API
const products = await db.product.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
// ProductList es un Client Component — le pasamos datos serializables
return (
<main>
<h1>Productos</h1>
<ProductList items={products} />
</main>
);
}// app/products/ProductList.tsx — Client Component
"use client";
import { useState, useMemo } from "react";
import { useLocale } from "next-intl";
export function ProductList({ items }: { items: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const locale = useLocale();
// Crear el formateador en el lado del cliente
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}La Concepción Errónea de las "Islas"#
Inicialmente pensé en los Client Components como "islas" — pequeñas partes interactivas en un mar de contenido renderizado en el servidor. Eso es parcialmente correcto pero pierde un detalle crucial: un Client Component puede renderizar Server Components si se pasan como children o props.
Esto significa que el límite no es un muro sólido. Es más como una membrana. El contenido renderizado en el servidor puede fluir a través de los Client Components mediante el patrón children. Profundizaremos en esto en la sección de composición.
Patrones de Obtención de Datos#
RSC cambia la obtención de datos fundamentalmente. No más useEffect + useState + estados de carga para datos que se conocen en el momento del renderizado. Pero los nuevos patrones tienen sus propios trucos.
Fetch Básico con Caché#
En un Server Component, simplemente usas fetch. Next.js extiende el fetch global para añadir caché:
// app/products/page.tsx
export default async function ProductsPage() {
// Con caché por defecto — la misma URL devuelve resultado cacheado
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Controla el comportamiento de la caché explícitamente:
// Revalidar cada 60 segundos (comportamiento tipo ISR)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// Sin caché — siempre datos frescos
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Caché con tags para revalidación bajo demanda
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Luego puedes revalidar por tag desde un Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Obtención de Datos en Paralelo#
El error de rendimiento más común que veo: obtención secuencial de datos cuando la paralela funcionaría perfectamente.
Mal — secuencial (cascadas):
// app/dashboard/page.tsx — NO HAGAS ESTO
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — cada uno espera al anterior
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Bien — paralelo:
// app/dashboard/page.tsx — HAZ ESTO
export default async function Dashboard() {
// Los tres se lanzan simultáneamente
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (se ejecuta en paralelo)
getNotifications(), // 150ms (se ejecuta en paralelo)
]);
// Total: ~300ms — limitado por la solicitud más lenta
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Aún mejor — paralelo con límites Suspense independientes:
// app/dashboard/page.tsx — LO MEJOR
import { Suspense } from "react";
export default function Dashboard() {
// Nota: este componente NO es async — delega a los hijos
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Cada componente obtiene sus propios datos
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}Este último patrón es el más poderoso porque cada sección carga independientemente. El usuario ve contenido a medida que está disponible, no todo o nada. Las secciones rápidas no esperan a las lentas.
Deduplicación de Solicitudes#
Next.js automáticamente deduplica las llamadas fetch con la misma URL y opciones durante un solo pase de renderizado. Esto significa que no necesitas elevar la obtención de datos para evitar solicitudes redundantes:
// Ambos componentes pueden hacer fetch a la misma URL
// y Next.js solo hará UNA solicitud HTTP real
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Bienvenido, {user.name}</nav>;
}
async function Sidebar() {
// Misma URL — automáticamente deduplicada, no es una segunda solicitud
const user = await fetch("/api/user").then(r => r.json());
return <aside>Rol: {user.role}</aside>;
}Advertencia importante: esto solo funciona con fetch. Si estás usando un ORM o cliente de base de datos directamente, necesitas usar la función cache() de React:
import { cache } from "react";
import { db } from "@/lib/database";
// Envuelve tu función de datos con cache()
// Ahora múltiples llamadas en el mismo render = una sola consulta real
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() deduplica durante el tiempo de vida de una sola solicitud del servidor. No es una caché persistente — es una memoización por solicitud. Después de que la solicitud termina, los valores cacheados se recolectan como basura.
Patrones de Composición de Componentes#
Aquí es donde RSC se vuelve genuinamente elegante, una vez que entiendes los patrones. Y genuinamente confuso, hasta que lo haces.
El Patrón "Children como un Agujero"#
Este es el patrón de composición más importante en RSC y me tomó semanas apreciarlo completamente. Aquí está el problema: tienes un Client Component que proporciona algún layout o interactividad, y quieres renderizar Server Components dentro de él.
No puedes importar un Server Component en un archivo de Client Component. En el momento en que añades "use client", todo en ese módulo es código del lado del cliente. Pero puedes pasar Server Components como children:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Cerrar" : "Abrir"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* ¡Estos children pueden ser Server Components! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* Estos son Server Components, pasados a través de un Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}¿Por qué funciona esto? Porque los Server Components (UserProfile, NavigationLinks) se renderizan primero en el servidor, luego su salida (el payload RSC) se pasa como children al Client Component. El Client Component nunca necesita saber que eran Server Components — solo recibe nodos React pre-renderizados.
Piensa en children como un "agujero" en el Client Component por donde puede fluir el contenido renderizado en el servidor.
Pasar Server Components como Props#
El patrón children se generaliza a cualquier prop que acepte React.ReactNode:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — puede obtener datos
import { BillingSettings } from "./BillingSettings"; // Server Component — puede obtener datos
import { SecuritySettings } from "./SecuritySettings"; // Server Component — puede obtener datos
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Perfil", content: <ProfileSettings /> },
{ label: "Facturación", content: <BillingSettings /> },
{ label: "Seguridad", content: <SecuritySettings /> },
]}
/>
);
}Cada componente de configuración puede ser un Server Component async que obtiene sus propios datos. El Client Component (TabLayout) solo maneja el cambio de pestaña. Este es un patrón increíblemente poderoso.
Server Components Async#
Los Server Components pueden ser async. Esto es un gran avance porque significa que la obtención de datos ocurre durante el renderizado, no como un efecto secundario:
// Esto es válido y hermoso
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>Por {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Los Client Components no pueden ser async. Si intentas hacer un componente "use client" async, React lanzará un error. Esta es una restricción fuerte.
Límites Suspense: La Primitiva de Streaming#
Suspense es cómo obtienes streaming en RSC. Sin límites Suspense, toda la página espera al componente async más lento. Con ellos, cada sección hace streaming independientemente:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Estático — se renderiza inmediatamente */}
<HeroSection />
{/* Datos rápidos — se muestra rápidamente */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Velocidad media — se muestra cuando está listo */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Lento (potenciado por ML) — se muestra último, no bloquea el resto */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}El usuario ve HeroSection instantáneamente, luego ProductGrid llega por streaming, luego reseñas, luego recomendaciones. Cada límite Suspense es un punto de streaming independiente.
Anidar límites Suspense también es válido y útil:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Si Dashboard es rápido pero RevenueChart es lento, el Suspense externo se resuelve primero (mostrando la estructura del dashboard), y el Suspense interno para el gráfico se resuelve después.
Error Boundaries con Suspense#
Combina Suspense con error.tsx para UIs resilientes:
// app/dashboard/error.tsx — Client Component (debe serlo)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Algo salió mal al cargar el dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Intentar de nuevo
</button>
</div>
);
}El archivo error.tsx automáticamente envuelve el segmento de ruta correspondiente en un React Error Boundary. Si cualquier Server Component en ese segmento lanza un error, la UI de error se muestra en lugar de hacer crash de toda la página.
Cuándo Usar Cuál: El Árbol de Decisiones#
Después de construir varias apps de producción con RSC, me he decidido por un marco de decisión claro. Este es el proceso de pensamiento real que sigo para cada componente:
Empieza con Server Components (el Predeterminado)#
Cada componente debería ser un Server Component a menos que haya una razón específica por la que no pueda serlo. Esta es la regla más importante.
Hazlo un Client Component Cuando:#
1. Usa APIs solo del navegador
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Cargando ubicación...</p>;
}2. Usa hooks de React que requieren estado o efectos
useState, useEffect, useReducer, useRef (para refs mutables), useContext — cualquiera de estos requiere "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Este componente DEBE ser un Client Component porque
// usa useState y gestiona la entrada del usuario
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
);
}3. Adjunta manejadores de eventos
onClick, onChange, onSubmit, onMouseEnter — cualquier comportamiento interactivo significa lado del cliente.
4. Usa bibliotecas del lado del cliente
Framer Motion, React Hook Form, Zustand, React Query (para obtención de datos del lado del cliente), cualquier biblioteca de gráficos que renderice a canvas o SVG interactivamente.
Mantenlo como Server Component Cuando:#
- Solo muestra datos (sin interacción del usuario)
- Obtiene datos de una base de datos o API
- Accede a recursos del backend (sistema de archivos, variables de entorno con secrets)
- Importa dependencias grandes que el cliente no necesita (parseadores de markdown, resaltadores de sintaxis, bibliotecas de fechas para formateo)
- Renderiza contenido estático o semi-estático
La Decisión Real en la Práctica#
Aquí va un ejemplo concreto. Estoy construyendo una página de producto:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — navegación estática, sin interactividad
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — solo texto
│ ├── ProductPrice (Server) — número formateado, sin interacción
│ └── AddToCartButton (Client) — onClick, gestiona estado del carrito
├── ProductDescription (Server) — markdown renderizado
├── Suspense
│ └── RelatedProducts (Server) — obtención de datos async, API lenta
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — formulario con validación
Observa el patrón: la estructura de la página y las partes con muchos datos son Server Components. Las islas interactivas (ImageGallery, AddToCartButton, ReviewForm) son Client Components. Las secciones lentas (RelatedProducts, ProductReviews) están envueltas en Suspense.
Esto no es teórico. Así es como realmente lucen mis árboles de componentes.
Errores Comunes (Los He Cometido Todos)#
Error 1: Hacer Todo un Client Component#
El camino de menor resistencia al migrar desde Pages Router o Create React App es poner "use client" en todo. ¡Funciona! ¡Nada se rompe! También estás enviando todo tu árbol de componentes como JavaScript y obteniendo cero beneficios de RSC.
He visto codebases donde el layout raíz tiene "use client". En ese punto literalmente estás ejecutando una app React del lado del cliente con pasos extra.
La solución: Empieza con Server Components. Solo añade "use client" cuando el compilador te diga que es necesario (porque usaste un hook o un manejador de eventos). Empuja "use client" lo más abajo posible en el árbol.
Error 2: Prop Drilling a Través del Límite#
// MAL: obteniendo datos en un Server Component, luego pasándolos a través de
// múltiples Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}Cada dato que pasas a través del límite se serializa en el payload RSC. ¿Pasas el mismo objeto cinco veces? Está en el payload cinco veces. He visto payloads RSC inflarse a megabytes por esto.
La solución: Usa composición. Pasa Server Components como children en lugar de pasar datos como props:
// BIEN: Server Components obtienen sus propios datos, se pasan como children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — obtiene sus propios datos */}
<Settings /> {/* Server Component — obtiene sus propios datos */}
<ClientWidget>
<UserAvatar /> {/* Server Component — obtiene sus propios datos */}
</ClientWidget>
</ClientShell>
);
}Error 3: No Usar Suspense#
Sin Suspense, el Time to First Byte (TTFB) de tu página está limitado por tu obtención de datos más lenta. Tenía una página de dashboard que tardaba 4 segundos en cargar porque una consulta de analytics era lenta, aunque el resto de los datos de la página estaban listos en 200ms.
// MAL: todo espera a todo
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← bloquea todo
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// BIEN: analytics carga independientemente
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Tarda 4s pero no bloquea el resto */}
</Suspense>
</div>
);
}Error 4: Errores de Serialización en Runtime#
Este es particularmente doloroso porque a menudo no lo detectas hasta producción. Pasas algo no serializable a través del límite y obtienes un error críptico:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
Culpables comunes:
- Pasar objetos Date (usa
.toISOString()en su lugar) - Pasar Map o Set (convierte a arrays/objetos)
- Pasar instancias de clase de ORMs (usa
.toJSON()o spread en objetos planos) - Pasar funciones (mueve la lógica al Client Component o usa Server Actions)
- Pasar resultados de modelos Prisma con campos
Decimal(convierte anumberostring)
// MAL
const user = await prisma.user.findUnique({ where: { id } });
// user podría tener campos no serializables (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// BIEN
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;Error 5: Usar Context para Todo#
useContext solo funciona en Client Components. Si intentas usar un context de React en un Server Component, no funcionará. He visto gente hacer toda su app un Client Component solo para usar un context de tema.
La solución: Para temas y otro estado global, usa variables CSS establecidas en el lado del servidor, o usa las funciones cookies() / headers():
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}Para estado genuinamente del lado del cliente (tokens de auth, carritos de compra, datos en tiempo real), crea un provider Client Component delgado en el nivel apropiado — no en la raíz:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider es un Client Component, pero los children fluyen como contenido del servidor
return <CartProvider>{children}</CartProvider>;
}Error 6: Ignorar el Impacto en el Tamaño del Bundle#
Una de las mayores victorias de RSC es que el código de Server Components nunca se envía al cliente. Pero necesitas pensar en esto activamente. Si tienes un componente que usa un parseador de markdown de 50KB y solo muestra contenido renderizado — eso debería ser un Server Component. El parseador se queda en el servidor, y solo la salida HTML va al cliente.
// Server Component — marked se queda en el servidor
import { marked } from "marked"; // Biblioteca de 50KB — nunca se envía al cliente
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Si hicieras esto un Client Component, marked se enviaría al navegador. Para nada. El usuario descargaría 50KB de JavaScript solo para renderizar contenido que podría haber sido HTML desde el principio.
Revisa tu bundle con @next/bundle-analyzer. Los resultados podrían sorprenderte.
Estrategia de Caché#
El caché en Next.js 15+ ha sido significativamente simplificado comparado con versiones anteriores, pero aún hay capas distintas que entender.
La Función cache() (React)#
La función cache() de React es para deduplicación por solicitud, no caché persistente:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Llama esto en cualquier lugar de tu árbol de componentes durante una sola solicitud.
// Solo se ejecutará una consulta real a la base de datos.Esto tiene scope de una sola solicitud del servidor. Cuando la solicitud termina, el valor cacheado desaparece. Es memoización, no caché.
unstable_cache (Next.js)#
Para caché persistente entre solicitudes, usa unstable_cache (el nombre ha sido "unstable" para siempre, pero funciona bien en producción):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // prefijo de clave de caché
{
revalidate: 3600, // revalidar cada hora
tags: ["products"], // para revalidación bajo demanda
}
);
// Uso en un Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}Para invalidar:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}Renderizado Estático vs Dinámico#
Next.js decide si una ruta es estática o dinámica basándose en lo que usas en ella:
Estático (renderizado en tiempo de build, cacheado):
- Sin funciones dinámicas (
cookies(),headers(),searchParams) - Todas las llamadas
fetchtienen caché habilitado - Sin
export const dynamic = "force-dynamic"
Dinámico (renderizado por solicitud):
- Usa
cookies(),headers(), osearchParams - Usa
fetchconcache: "no-store" - Tiene
export const dynamic = "force-dynamic" - Usa
connection()oafter()denext/server
Puedes verificar qué rutas son estáticas vs dinámicas ejecutando next build — muestra una leyenda al final:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
La Jerarquía de Caché#
Piensa en el caché por capas:
1. React cache() — por solicitud, en memoria, dedup automático
2. fetch() cache — entre solicitudes, automático para solicitudes GET
3. unstable_cache() — entre solicitudes, para operaciones que no son fetch
4. Full Route Cache — HTML renderizado cacheado en tiempo de build/revalidación
5. Router Cache (cliente) — caché en el navegador de rutas visitadas
Cada capa sirve un propósito diferente. No siempre necesitas todas, pero entender cuál está activa ayuda a depurar problemas de "¿por qué mis datos no se actualizan?"
Una Estrategia de Caché del Mundo Real#
Esto es lo que realmente hago en producción:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Dedup por solicitud: llama esto múltiples veces en un render,
// solo se ejecuta una consulta a BD
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Caché entre solicitudes: los resultados persisten entre solicitudes,
// se revalidan cada 5 minutos o bajo demanda vía tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// Sin caché: siempre fresco (para datos específicos del usuario)
export const getUserCart = cache(async (userId: string) => {
// cache() aquí es solo para dedup por solicitud, no persistencia
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});La regla general: los datos públicos que cambian infrecuentemente usan unstable_cache. Los datos específicos del usuario usan cache() solo para dedup. Los datos en tiempo real no usan caché en absoluto.
Server Actions: El Puente de Vuelta#
Los Server Actions merecen su propia sección porque completan la historia de RSC. Son cómo los Client Components se comunican de vuelta con el servidor sin rutas de API.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Dirección de email inválida" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Ya estás suscrito" };
}
return { error: "Algo salió mal" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="tu@ejemplo.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Suscribiendo..." : "Suscribirse"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">¡Suscrito!</p>}
</form>
);
}Los Server Actions son la respuesta a "¿cómo muto datos?" en el mundo RSC. Reemplazan la mayoría de las rutas de API para envíos de formularios, mutaciones y efectos secundarios.
Reglas clave para Server Actions:
- Siempre valida la entrada (la función es llamable desde el cliente — trátala como un endpoint de API)
- Siempre devuelve datos serializables
- Se ejecutan en el servidor, así que puedes acceder a bases de datos, sistemas de archivos, secrets
- Pueden llamar a
revalidatePath()orevalidateTag()para actualizar datos cacheados después de mutaciones
Patrones de Migración#
Si tienes una app React existente (Pages Router, Create React App, Vite), moverse a RSC no tiene que ser una reescritura. Así es como lo abordo.
Paso 1: Mapea Tus Componentes#
Recorre tu árbol de componentes y clasifica todo:
Componente ¿Estado? ¿Efectos? ¿Eventos? → Decisión
─────────────────────────────────────────────────────────
Header No No No → Server
NavigationMenu No No Sí → Client (toggle móvil)
Footer No No No → Server
BlogPost No No No → Server
SearchBar Sí Sí Sí → Client
ProductCard No No Sí → Client (onClick) o dividir
UserAvatar No No No → Server
CommentForm Sí Sí Sí → Client
Sidebar Sí No Sí → Client (toggle colapso)
MarkdownRenderer No No No → Server (gran ganancia en dependencias)
DataTable Sí Sí Sí → Client (ordenar, filtrar)
Paso 2: Mueve la Obtención de Datos Arriba#
El mayor cambio arquitectónico es mover la obtención de datos de useEffect en componentes a Server Components async. Aquí es donde vive el verdadero esfuerzo de migración.
Antes:
// Patrón antiguo — obtención de datos en un Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}Después:
// Nuevo patrón — Server Component obtiene, Client Component interactúa
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}Sin gestión de estado de carga. Sin estado de error. Sin useEffect. El framework maneja todo eso a través de Suspense y error boundaries.
Paso 3: Divide Componentes en los Límites de Interacción#
Muchos componentes son mayormente estáticos con una pequeña parte interactiva. Divídelos:
Antes (un gran Client Component):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}Después (Server Component con una pequeña isla Client):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Quitar de favoritos" : "Añadir a favoritos"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}La imagen, título, descripción y precio ahora se renderizan en el servidor. Solo el pequeño botón de favorito es un Client Component. Menos JavaScript, cargas de página más rápidas.
Paso 4: Convierte Rutas de API en Server Actions#
Si tienes rutas de API que existen únicamente para servir a tu propio frontend (no consumidores externos), la mayoría pueden convertirse en Server Actions:
Antes:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validar, guardar en BD, enviar email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});Después:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validar, guardar en BD, enviar email
return { success: true };
}
// Client component — simplemente llama a la función directamente
import { submitContactForm } from "@/app/actions/contact";Mantén rutas de API para: webhooks, consumidores de API externos, cualquier cosa que necesite headers HTTP personalizados o códigos de estado, subidas de archivos con streaming.
Paso 5: Testing de Componentes RSC#
Testear Server Components requiere un enfoque ligeramente diferente ya que pueden ser async:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock de la base de datos
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// Los Server Components son async — await del JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});La diferencia clave: haces await a la función del componente porque es async. Luego renderizas el JSX resultante. Todo lo demás funciona igual que con React Testing Library tradicional.
Estructura de Archivos para Proyectos RSC#
Esta es la estructura en la que he convergido después de varios proyectos. Es con opinión, pero funciona:
src/
├── app/
│ ├── layout.tsx ← Layout raíz (Server Component)
│ ├── page.tsx ← Página principal (Server Component)
│ ├── (marketing)/ ← Grupo de rutas para páginas de marketing
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Grupo de rutas para la app autenticada
│ │ ├── layout.tsx ← Shell de la app con verificación de auth
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Fallback de Suspense para esta ruta
│ │ │ └── error.tsx ← Error boundary para esta ruta
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Primitivas UI compartidas (mayormente Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (solo estilo)
│ └── features/ ← Componentes específicos de features
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, obtiene datos)
│ │ ├── ProductCard.tsx ← Server (presentacional)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutación)
│ └── blog/
│ ├── PostList.tsx ← Server (async, obtiene datos)
│ ├── PostContent.tsx ← Server (renderizado de markdown)
│ └── CommentSection.tsx ← Client (formulario, tiempo real)
├── lib/
│ ├── data/ ← Capa de acceso a datos
│ │ ├── products.ts ← Consultas a BD envueltas en cache()
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — envuelve partes que necesitan tema
└── CartProvider.tsx ← "use client" — envuelve solo la sección de tienda
Los principios clave:
- Los Server Components no tienen directiva — son el predeterminado
- Los Client Components están explícitamente marcados — puedes saberlo de un vistazo
- La obtención de datos vive en
lib/data/— envuelta concache()ounstable_cache - Los Server Actions viven en
app/actions/— co-ubicados con la app, claramente separados - Los Providers envuelven lo mínimo necesario — no toda la app
La Conclusión#
React Server Components no son solo una nueva API. Son una forma diferente de pensar sobre dónde se ejecuta el código, dónde viven los datos y cómo se conectan las piezas. El cambio de modelo mental es real y toma tiempo.
Pero una vez que hace clic — una vez que dejas de luchar contra el límite y empiezas a diseñar alrededor de él — terminas con apps que son más rápidas, más simples y más mantenibles que lo que construíamos antes. Menos JavaScript se envía al cliente. La obtención de datos no requiere ceremonia. El árbol de componentes se convierte en la arquitectura.
La transición vale la pena. Solo ten en cuenta que los primeros proyectos se sentirán incómodos, y eso es normal. No estás luchando porque RSC sea malo. Estás luchando porque es genuinamente nuevo.
Empieza con Server Components en todas partes. Empuja "use client" hacia las hojas. Envuelve las cosas lentas en Suspense. Obtén datos donde se renderizan. Compón a través de children.
Ese es todo el manual. Todo lo demás son detalles.