Patrones de Composición
Mezcla Server Components y Client Components de forma efectiva utilizando el patrón children, slots y arquitectura consciente de límites.
Busca en todas las páginas de la documentación
Mezcla Server Components y Client Components de forma efectiva utilizando el patrón children, slots y arquitectura consciente de límites.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Patrón 1: Pasar Server Components como children a Client Components
// app/components/client-sidebar.tsx
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<aside className={open ? "w-64" : "w-0"}>
<button onClick={() => setOpen(!open)}>Alternar</button>
{open && children}
</aside>
);
}
// app/page.tsx (Server Component)
import { Sidebar } from "./components/client-sidebar";
import { ServerNav } from "./components/server-nav";
export default function Page() {
return (
<Sidebar>
{/* ServerNav se renderiza en el servidor, pasado como JSX pre-renderizado */}
<ServerNav />
</Sidebar>
);
}Cuándo usarlo: Necesitas un Client Component (para interactividad) que envuelva o contenga Server Components (para obtención de datos o renderizado sin JS).
// app/components/accordion.tsx
"use client";
import { useState } from "react";
type AccordionProps = {
title: string;
children: React.ReactNode;
};
export function Accordion({ title, children }: AccordionProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border rounded mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-3 font-medium flex justify-between"
>
{title}
<span>{expanded ? "-" : "+"}</span>
</button>
{expanded && <div className="px-4 pb-4">{children}</div>}
</div>
);
}// app/components/product-details.tsx (Server Component -- sin directiva)
import { db } from "@/lib/db";
export async function ProductDetails({ productId }: { productId: string }) {
const product = await db.product.findUnique({
where: { id: productId },
include: { specs: true },
});
if (!product) return <p>Producto no encontrado</p>;
return (
<dl className="grid grid-cols-2 gap-2 text-sm">
{product.specs.map((spec) => (
<div key={spec.id}>
<dt className="font-medium text-gray-600">{spec.label}</dt>
<dd>{spec.value}</dd>
</div>
))}
</dl>
);
}// app/components/product-reviews.tsx (Server Component)
import { db } from "@/lib/db";
export async function ProductReviews({ productId }: { productId: string }) {
const reviews = await db.review.findMany({
where: { productId },
orderBy: { createdAt: "desc" },
take: 5,
});
return (
<ul className="space-y-3">
{reviews.map((r) => (
<li key={r.id} className="border-b pb-3">
<p className="font-medium">{r.author}</p>
<p className="text-gray-600">{r.body}</p>
</li>
))}
</ul>
);
}// app/products/[id]/page.tsx (Server Component orquesta todo)
import { Suspense } from "react";
import { Accordion } from "@/app/components/accordion";
import { ProductDetails } from "@/app/components/product-details";
import { ProductReviews } from "@/app/components/product-reviews";
import { AddToCartButton } from "@/app/components/add-to-cart";
type Props = { params: Promise<{ id: string }> };
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<main className="max-w-2xl mx-auto p-6">
<Accordion title="Especificaciones">
{/* Server Component renderizado en el servidor, pasado como children */}
<Suspense fallback={<p>Cargando especificaciones...</p>}>
<ProductDetails productId={id} />
</Suspense>
</Accordion>
<Accordion title="Reseñas">
<Suspense fallback={<p>Cargando reseñas...</p>}>
<ProductReviews productId={id} />
</Suspense>
</Accordion>
{/* Client Component para interactividad */}
<AddToCartButton productId={id} />
</main>
);
}// app/components/add-to-cart.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "@/app/actions/cart";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => addToCart(productId))}
disabled={isPending}
className="mt-4 w-full bg-blue-600 text-white py-3 rounded font-medium disabled:opacity-50"
>
{isPending ? "Agregando..." : "Agregar al Carrito"}
</button>
);
}Lo que esto demuestra:
Accordion) envuelve Server Components (ProductDetails, ProductReviews) a través de props childrenawait y se renderan en el servidor; su salida se pasa como JSX pre-renderizado al Client ComponentSuspense permite que los datos del servidor hagan streaming de forma independiente"use client" hace que todo en su árbol de dependencias sea solo del cliente.children o cualquier otra prop React.ReactNode. El Server Component ya está renderizado en el servidor; el Client Component recibe JSX pre-renderizado, no una referencia de módulo."use client". Todo lo anterior (en el árbol de importación) es servidor; todo lo posterior es cliente.children).Patrón de múltiples slots:
// Client Component con slots nombrados
"use client";
export function DashboardLayout({
sidebar,
header,
children,
}: {
sidebar: React.ReactNode;
header: React.ReactNode;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex">
<aside className={collapsed ? "w-16" : "w-64"}>{sidebar}</aside>
<div className="flex-1">
<header>{header}</header>
<main>{children}</main>
</div>
</div>
);
}// Orquestador de Server Component
import { DashboardLayout } from "./dashboard-layout";
import { ServerSidebar } from "./server-sidebar";
import { ServerHeader } from "./server-header";
export default async function DashboardPage() {
return (
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>
);
}Patrón de context provider:
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class">
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Extrayendo partes interactivas en un Client Component delgado:
// En lugar de hacer toda la tarjeta un Client Component...
// Extrae solo la parte interactiva
"use client";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
);
}
// Mantén la tarjeta como Server Component
export default async function PostCard({ id }: { id: string }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
<LikeButton postId={id} />
</article>
);
}// Usa React.ReactNode para cualquier prop que reciba JSX del servidor
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};
// La salida de Server Component es JSX ya renderizado -- no una referencia de componente
// TypeScript lo trata como ReactNode, que incluye JSX.Element, string, number, etc.
// Las props de Server Action son válidas a través del límite
type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Importar un Server Component en un archivo "use client" -- La importación silenciosamente convierte el Server Component en un Client Component. No se lanza error, pero pierde el comportamiento solo del servidor. Solución: Pásalo como children o una prop JSX desde un padre Server Component.
Los context providers deben ser Client Components -- React Context requiere "use client". Pero poner providers en el layout hace que el layout sea un componente cliente y todos los children se convierten en cliente también. Solución: Crea un archivo providers.tsx separado con "use client" y envuelve {children} en el layout del Server Component.
Usar demasiado "use client" -- Marcar un componente de alto nivel como "use client" tira todos los children al bundle del cliente. Solución: Empuja el límite "use client" lo más bajo posible en el árbol de componentes. Extrae solo las piezas interactivas.
Pasar props no serializables -- Pasar una función, instancia de clase, o Symbol desde un Server Component a un Client Component falla silenciosamente o lanza error. Solución: Pasa solo datos serializables. Usa Server Actions para comportamiento similar a funciones.
Estado compartido entre servidor y cliente -- No hay estado compartido. Los Server Components se ejecutan en el servidor; los Client Components se hidratan en el cliente. Solución: Pasa datos iniciales como props del servidor al cliente. Usa Server Actions para sincronizar estado de vuelta.
| Patrón | Úsalo Cuando | No Lo Uses Cuando |
|---|---|---|
| Patrón children | Client Component envuelve salida de Server Component | Todos los children son solo del cliente de todas formas |
| Props de slot (sidebar, header) | Múltiples regiones independientes de Server Component | Una única prop children es suficiente |
| Wrapper de context providers | Necesitas React Context en la raíz sin hacer que el layout sea un Client Component | No se necesita contexto |
dynamic(import, { ssr: false }) | Una librería de terceros no puede renderizar en el servidor en absoluto | SSR + hidratación normal funciona bien |
| Server Actions como props | Un Client Component necesita desencadenar lógica del lado del servidor | La interacción es puramente del lado del cliente |
children (u otra prop JSX) a un Client Component.El límite "use client" hace que todo en su árbol de dependencias sea solo del cliente. Importar un Server Component dentro de un archivo "use client" silenciosamente lo convierte en un Client Component, perdiendo todo el comportamiento solo del servidor.
Un Client Component acepta múltiples props React.ReactNode (por ejemplo, sidebar, header, children). Un orquestador de Server Component pasa diferentes Server Components en cada slot:
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>Crea un archivo providers.tsx separado con "use client" que envuelva {children}. Impórtalo en tu layout del Server Component:
// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html><body>
<Providers>{children}</Providers>
</body></html>
);
}Todos los children e importaciones de ese componente se tiran al bundle del cliente. Esto derrota el propósito de los Server Components. Solución: empuja el límite "use client" lo más bajo posible y extrae solo las piezas interactivas.
children o una prop JSX desde un padre Server Component en su lugar.No. Las funciones regulares, instancias de clase, y Symbols no son serializables y fallarán. Usa Server Actions (funciones async con "use server") para comportamiento similar a funciones a través del límite.
Usa React.ReactNode para cualquier prop que reciba JSX del servidor:
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Las Server Actions son las únicas funciones que pueden cruzar el límite servidor-cliente como props.
Suspense permite que su contenido haga streaming de forma independiente.No. Los Server Components se ejecutan en el servidor; los Client Components se hidratan en el cliente. Pasa datos iniciales como props del servidor al cliente. Usa Server Actions para sincronizar estado de vuelta al servidor.
Mantén el componente que obtiene datos como Server Component y extrae solo la pieza interactiva (por ejemplo, un botón) en un pequeño Client Component:
// Server Component
export default async function PostCard({ id }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<LikeButton postId={id} /> {/* Client Component */}
</article>
);
}Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥