Con la llegada de la arquitectura React Server Components (RSC), los desarrolladores se enfrentaron a un desafío fundamentalmente nuevo: dividir una base de código común a través de los límites físicos del servidor y el cliente dentro del mismo árbol visual de interfaz.
En React tradicional, la composición de componentes era trivial: cualquier componente podía importar libremente cualquier otro archivo. En Next.js App Router rigen límites de red físicos estrictos (Network Boundary). Los fallos en el diseño arquitectónico conducen a:
fs, controladores de bases de datos, tokens privados) dentro del bundle del navegador.useState, useEffect) o React Context sin perder las ventajas inherentes del servidor.Los patrones de composición representan un conjunto riguroso de soluciones arquitectónicas que permiten intercalar componentes de servidor y cliente (Interleaving), mantener el tamaño del bundle del cliente cercano a cero y transferir datos a través del límite de red sin violar las reglas de aislamiento de RSC.
En Next.js, la raíz del árbol de componentes (layout.tsx, page.tsx) siempre se ejecuta en el servidor.
La regla del compilador Turbopack es categórica: la directiva 'use client' infecta en cascada todo su subárbol de importaciones. Si importas un componente en un archivo marcado con 'use client', ese componente (y todas sus dependencias transitivas) se compilan automáticamente para el bundle del cliente.
Inspeccione los límites de serialización de React Flight y el impacto de 'use client' en el bundle
La directiva 'use client' se coloca en la hoja interactiva (el botón). Los datos y parsers pesados se ejecutan en el servidor y no entran en el bundle JS.
El principio rector para los arquitectos de Fullstack Agentic Lab: empujar 'use client' lo más cerca posible de las hojas del árbol, y cuando el contenido del servidor deba residir dentro de un contenedor interactivo de cliente, emplear el Donut Pattern (Patrón del dónut).
Permitido directamente vía import. Se ejecuta estrictamente en el servidor; 0 KB en el bundle del navegador.
Permitido directamente vía import. Las props deben ser serializables (tipos JSON, Promises, slots JSX).
Permitido directamente vía import. Árbol de React convencional ejecutándose en el navegador.
Estrictamente prohibido. La importación directa fuerza al compilador a incluir el Server Component en el bundle del cliente.
Permitido. Donut Pattern: el Server Component se evalúa externamente en el servidor y se inyecta como descriptor ReactNode.
¿Qué ocurre si un Client Component (declarado con 'use client') importa directamente un Server Component: import { ServerFeed } from './ServerFeed'?
Cuando un componente interactivo de cliente (como una barra lateral plegable con animación) necesita renderizar en su interior datos pesados del servidor, utilizamos la prop children:
'use client';
import { useState } from 'react';
interface CollapsibleSidebarProps {
children: React.ReactNode; // Slot del servidor
}
export function CollapsibleSidebar({ children }: CollapsibleSidebarProps) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="flex">
<aside className={`transition-all duration-300 ${isOpen ? 'w-64' : 'w-16'}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="p-2 border border-border rounded bg-bg-elevated text-xs mb-4"
>
{isOpen ? 'Plegar' : 'Desplegar'}
</button>
{/* Aquí se renderiza el nodo del servidor evaluado. ¡El código de BD no entra al bundle! */}
<div className={isOpen ? 'block' : 'hidden'}>
{children}
</div>
</aside>
</div>
);
}import { CollapsibleSidebar } from '@/components/CollapsibleSidebar';
import { ServerNavigationTree } from '@/components/ServerNavigationTree'; // Librería de servidor + consulta BD
export default async function DashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Panel de control</h1>
{/* Donut Pattern: inyectamos el Server Component dentro del contenedor de cliente */}
<CollapsibleSidebar>
<ServerNavigationTree />
</CollapsibleSidebar>
</main>
);
}Si la interfaz requiere estructurarse en varias zonas independientes (por ejemplo, cabecera, barra lateral y área de contenido), el Client Component declara Named Slots (Ranuras con nombre):
'use client';
import { useState } from 'react';
interface SplitLayoutProps {
sidebarSlot: React.ReactNode;
contentSlot: React.ReactNode;
}
export function SplitLayout({ sidebarSlot, contentSlot }: SplitLayoutProps) {
const [activePane, setActivePane] = useState<'both' | 'content'>('both');
return (
<div className="flex gap-6 min-h-screen">
{activePane === 'both' && (
<aside className="w-1/3 border-r border-border p-4">
<button
type="button"
onClick={() => setActivePane('content')}
className="text-xs text-accent-cyan mb-3 block"
>
Enfocar contenido →
</button>
{sidebarSlot}
</aside>
)}
<section className="flex-1 p-4">
{activePane === 'content' && (
<button
type="button"
onClick={() => setActivePane('both')}
className="text-xs text-accent-cyan mb-3 block"
>
← Mostrar barra lateral
</button>
)}
{contentSlot}
</section>
</div>
);
}El hook useContext no está disponible en Server Components. Sin embargo, librerías esenciales (Next-Themes, TanStack Query, Zustand) exigen envolver la aplicación en un Context Provider.
Solución: aislar el proveedor en un Client Component dedicado que acepte {children}:
'use client';
import { ThemeProvider } from 'next-themes';
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{/* ¡Todos los Server Components anidados conservan su ejecución de servidor! */}
{children}
</ThemeProvider>
);
}import { AppProviders } from '@/components/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning>
<body className="bg-bg-base text-text-primary">
{/* ¡El layout raíz del servidor sigue siendo un Server Component! */}
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}En lugar de declarar toda una página como cliente por culpa de un único campo de búsqueda, aísla la interactividad en un componente hoja:
'use client';
import { useTransition } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export function SearchInput({ defaultValue = '' }: { defaultValue?: string }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(window.location.search);
if (term) {
params.set('q', term);
} else {
params.delete('q');
}
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
type="search"
defaultValue={defaultValue}
placeholder="Buscar en la documentación..."
onChange={(e) => handleSearch(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-bg-elevated border border-border text-sm focus:outline-none focus:border-accent-cyan"
/>
{isPending && <span className="absolute right-3 top-2.5 text-xs text-text-muted animate-spin">⏳</span>}
</div>
);
}import { SearchInput } from '@/components/SearchInput';
import { HeavyArticleList } from '@/components/HeavyArticleList'; // Server Component con analizador markdown
interface PageProps {
searchParams: Promise<{ q?: string }>;
}
export default async function DocsPage({ searchParams }: PageProps) {
const { q } = await searchParams;
return (
<div className="max-w-4xl mx-auto p-8 space-y-6">
<h1 className="text-3xl font-bold">Base de conocimiento</h1>
{/* Componente hoja interactivo de cliente */}
<SearchInput defaultValue={q} />
{/* Lista del servidor: 0 bytes de JS en el bundle del cliente */}
<HeavyArticleList query={q ?? ''} />
</div>
);
}Los botones de cliente pueden ejecutar mutaciones de servidor sin necesidad de crear manualmente Route Handlers (/api/...), recibiendo Server Actions como props:
'use client';
import { useTransition } from 'react';
interface ActionButtonProps {
action: () => Promise<void>; // ¡Server Action transferida vía props!
label: string;
}
export function ActionButton({ action, label }: ActionButtonProps) {
const [isPending, startTransition] = useTransition();
return (
<button
type="button"
disabled={isPending}
onClick={() => startTransition(async () => { await action(); })}
className="px-4 py-2 rounded-lg bg-accent-violet text-white text-sm font-medium hover:bg-accent-violet/90 transition-colors disabled:opacity-50"
>
{isPending ? 'Ejecutando...' : label}
</button>
);
}¿Por qué un Server Component pasado como children a un Client Component NO se vuelve a renderizar ni pierde su naturaleza de servidor cuando se ejecuta setState en el padre de cliente?
Los Server Components no pueden pasar funciones síncronas convencionales en las props a Client Components porque las funciones no son serializables en el protocolo JSON/Flight de RSC (TypeError: Functions cannot be passed directly to Client Components).
Solución:
'use server' (serializada como identificador RPC seguro).// ❌ ERROR: Función flecha síncrona definida dentro de un Server Component
// return <ClientButton onClick={() => console.log('delete')} />;
// ✅ CORRECTO: Utilizar una Server Action para mutaciones de servidor
// app/actions.ts:
// 'use server';
// export async function deleteItemAction(id: string) { await db.item.delete({ where: { id } }); }
// app/page.tsx:
// <ClientButton action={deleteItemAction} />Cuando un Client Component importa un botón sencillo desde un archivo barril común (import { Button } from '@/components'), el empaquetador puede incluir en el bundle del cliente componentes pesados de servidor (como el editor Monaco) que se reexportan en el mismo archivo.
Solución: Utiliza importaciones directas de módulo en archivos de cliente (import { Button } from '@/components/ui/Button') o configura optimizePackageImports en next.config.ts.
Muchos desarrolladores agregan 'use client' directamente en app/layout.tsx para envolver la aplicación en ThemeProvider o SessionProvider. Este es un error arquitectónico crítico que convierte toda la aplicación en una SPA renderizada en el cliente, anulando las ventajas de RSC.
Solución: Crea un componente de cliente aislado AppProviders.tsx con la prop {children} y utilízalo dentro del RootLayout del servidor.
Si un módulo accede a credenciales de base de datos pero no está protegido con import 'server-only', un miembro del equipo puede importarlo accidentalmente en un Client Component. Turbopack no fallará en compilación inicial y las variables secretas quedarán expuestas en el código del navegador.
Solución: Instala pnpm add server-only y añade import 'server-only' al comienzo de todos los archivos de servicios de datos sensibles.