Next.js
Composición entre servidor y cliente
Explica cómo componer Server y Client Components mediante boundaries, slots, DTOs y providers sin ampliar bundles ni filtrar datos privados.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo componer Server y Client Components mediante boundaries, slots, DTOs y providers sin ampliar bundles ni filtrar datos privados.
Una aplicación App Router no se divide en “páginas de servidor” y “páginas de cliente” aisladas. El patrón más útil es componer estructura y datos en Server Components con regiones interactivas pequeñas en Client Components. La frontera se diseña por responsabilidad, no por carpeta completa.
La composición puede representarse así:
Server Component
├─ Server Component
├─ Client Component
│ ├─ client state
│ └─ browser events
└─ Client Component shell
└─ Server Component passed as childrenUn Server Component puede importar y renderizar Client Components. Un Client Component no puede importar directamente la implementación de un Server Component porque su grafo se compila para el navegador. Sin embargo, puede recibir contenido ya compuesto por el servidor mediante children u otra prop de tipo ReactNode.
Sin una estrategia de composición, suelen aparecer dos extremos:
La página carga rápido y reduce JavaScript, pero no puede manejar interacción local, Effects ni APIs del navegador.
La interacción es sencilla de implementar, pero aumenta bundle, hidratación y fetching posterior; además puede arrastrar SDKs o transformaciones que deberían permanecer privadas.
La composición permite mantener cada responsabilidad en el entorno adecuado:
datos, secretos y contenido inicial
→ servidor
state efímero, eventos y browser APIs
→ cliente// Server Component
import { AddToCartButton } from "./add-to-cart-button";
export async function ProductCard({ productId }: { productId: string }) {
const product = await getProduct(productId);
return (
<article>
<h2>{product.name}</h2>
<AddToCartButton productId={product.id} />
</article>
);
}El servidor compone la boundary y pasa props serializables.
"use client";
import { ProductDetails } from "./product-details"; // Server implementationAl importar el módulo desde una boundary cliente, el bundler intentaría incluirlo en el grafo cliente o detectaría dependencias incompatibles.
// Server Component
export default async function ProductPage() {
const product = await getProduct();
return (
<ProductDialog>
<ProductDetails product={product} />
</ProductDialog>
);
}// Client Component
"use client";
export function ProductDialog({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Ver detalles
</button>
{open && <div role="dialog">{children}</div>}
</>
);
}ProductDialog no importa ProductDetails; solo recibe el resultado React que el padre servidor ya compuso.
Parent Server Component
│
├─ ejecuta ProductDetails en servidor
├─ crea referencia al Client ProductDialog
└─ pasa el resultado como children
↓
RSC payload describe ambos
↓
navegador hidrata ProductDialog
↓
children servidor permanece renderizableEl Client Component controla cuándo y dónde mostrar el slot, pero no ejecuta la implementación privada del componente servidor.
Cuando una prop cruza la boundary, debe pertenecer al contrato de serialización compatible:
<ProductEditor
product={{
id: product.id,
name: product.name,
price: Number(product.price),
}}
/>type ProductEditorInput = {
id: string;
name: string;
price: number;
};No pases directamente:
Aunque React soporte tipos como Date, Map o Set en ciertos contratos actuales, los DTOs simples hacen explícito qué cruza y facilitan compatibilidad, logging y tests.
Con cacheComponents: true, un componente con use cache puede aceptar children o Server Actions como valores pass-through siempre que no los inspeccione dentro del scope cacheado:
async function CachedShell({ children }: { children: React.ReactNode }) {
"use cache";
const navigation = await getCachedNavigation();
return (
<div>
<Navigation items={navigation} />
{children}
</div>
);
}El shell se cachea, mientras el contenido dinámico puede componerse fuera y atravesar el slot.
No leas propiedades internas de un valor no serializable dentro del scope cacheado; entonces dejaría de ser pass-through y no podría formar una key predecible.
React Context para state cliente necesita provider cliente:
// app/providers.tsx
"use client";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<ToastProvider>{children}</ToastProvider>
</ThemeProvider>
);
}// app/layout.tsx — Server Component
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}El provider cliente recibe el árbol servidor como children. Esto no convierte automáticamente toda la implementación descendiente en módulos cliente.
Si un provider solo se usa en checkout:
app/checkout/layout.tsx
→ CheckoutProviderNo lo subas al root layout. Una boundary profunda reduce JavaScript en rutas no relacionadas y limita rerenders de Context.
No uses Context cliente solo para evitar repetir una consulta servidor. React cache puede deduplicar una función durante el render/request:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
return requireCurrentUser();
});Layout, metadata y page pueden llamar la misma función. Si una región cliente necesita esos datos, un provider puede recibir el resultado o incluso una Promise bajo un patrón compatible con use.
Distingue:
React.cache
→ memoización asociada al render/request
Next use cache
→ caché explícita reutilizable según key y lifetime
Context
→ distribución de valor dentro del árbol clienteNo son sustitutos directos.
Una Server Function puede cruzar hacia un formulario cliente:
// actions.ts
"use server";
export async function updateProfile(formData: FormData) {
const session = await requireSession();
const input = profileSchema.parse(Object.fromEntries(formData));
await profileRepository.update(session.user.id, input);
}// Server Component
<ProfileForm action={updateProfile} initialProfile={profileView} />// Client Component
"use client";
export function ProfileForm({
action,
initialProfile,
}: {
action: (formData: FormData) => Promise<void>;
initialProfile: ProfileView;
}) {
return <form action={action}>{/* fields */}</form>;
}La función no es una callback cliente normal. React y Next.js serializan una referencia al endpoint servidor generado.
La Action debe validar input, sesión y permisos porque puede invocarse directamente.
Caso: carrito lateral.
// Server Component
export default async function ShopLayout({ children }: { children: React.ReactNode }) {
const cartSummary = await getCartSummary();
return (
<CartDrawerProvider initialSummary={cartSummary}>
<ShopHeader />
{children}
</CartDrawerProvider>
);
}// Client Component
"use client";
export function CartDrawerProvider({
initialSummary,
children,
}: {
initialSummary: CartSummary;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<CartContext value={{ open, setOpen, initialSummary }}>
{children}
<CartDrawer />
</CartContext>
);
}El provider no debería almacenar una copia completa y permanente del server state si una caché de datos o nueva respuesta servidor es la fuente autoritativa.
// Server page
export default async function UserPage({ params }: PageProps<"/users/[userId]">) {
const { userId } = await params;
const user = await getAccessibleUser(userId);
return (
<Modal trigger={<OpenProfileButton />}>
<UserProfile user={user} />
</Modal>
);
}El trigger y modal pueden ser cliente; UserProfile sigue siendo contenido servidor.
Si el modal debe cargar otro usuario sin una navegación o nueva respuesta RSC, no puede pedirle al Server Component ya compuesto que se reejecute localmente. Necesitas:
Una biblioteca cliente puede envolverse:
"use client";
import { DatePicker as VendorDatePicker } from "vendor-date-picker";
export function DatePicker(props: DatePickerProps) {
return <VendorDatePicker {...props} />;
}El Server Component importa tu wrapper, no la dependencia directamente.
Para componentes que solo dependen del navegador durante montaje, comprueba si soportan SSR. dynamic(..., { ssr: false }) debe ser la última opción para una integración incompatible.
// server/billing.ts
import "server-only";Una boundary cliente que intente importarlo falla en build.
También puedes usar client-only en módulos que requieren navegador, aunque su uso es menos frecuente en código de aplicación.
Formatea en servidor cuando no necesitas locale del navegador ni actualización interactiva:
const view = {
total: formatCurrency(order.total, order.currency),
};Formatea en cliente cuando depende de preferencia local o cambia sin request.
La decisión debe ser explícita porque el mismo cálculo en servidor y cliente puede producir hydration mismatch por locale o timezone.
Pregunta para cada dato:
Server state: base de datos, inventario, sesión, permisos.
Client state: modal abierto, input, selección temporal.
URL, cookie, storage o persistencia servidor según el caso.
Evita duplicar una entidad completa en props, Context y store sin una estrategia de sincronización.
La composición puede ocultar código servidor, pero no datos enviados:
server repository
→ privado
product DTO passed to client
→ observable
Server Action ID/reference
→ invocableDefensa:
server-only.Aumenta JavaScript e hidratación.
Puede fragmentar props y complicar APIs sin ahorro significativo.
Carga código y actualiza consumidores en todas las rutas.
Aumenta RSC payload y serialización.
Permite mantener contenido pesado fuera del bundle cliente.
Mide bundles, RSC payload, hydration y tiempo de interacción. No optimices solo contando archivos use client.
Prueba que el loader autorizado produzca el DTO correcto.
Renderiza el Client Component con children de prueba y verifica eventos/focus.
Añade type tests o schemas para props serializadas.
Verifica HTML inicial, interacción, navegación y estado de error.
Comprueba que campos privados no aparezcan en HTML, RSC payload ni network responses.
Invierte la composición y pasa el resultado como slot.
Crea payload excesivo y riesgo de fuga. Usa DTO.
Amplía bundle y rerenders. Colócalo en la feature.
Pierde revalidation y consistencia. Separa client state de server state.
Solo Server Functions cruzan bajo contrato explícito.
Crea waterfall y dos fuentes. Reutiliza dato servidor o una herramienta de server state.
El slot fue producido por servidor; el cliente solo lo posiciona.
use cache: reutilización explícita entre renders/requests según configuración.use cache y Context?children o ReactNode.use cache crea una caché explícita; Context distribuye state cliente.Estrategias de rendering en Next.js separa dónde se ejecutan los componentes de cuándo se genera y reutiliza cada parte de la respuesta.