Server o Client Components: cuándo usar cada uno en Next.js
El modelo mental para elegir entre Server y Client Components en Next.js: el límite de renderizado, la serialización y los patrones de composición.

El límite de renderizado
En el App Router de Next.js, todo componente es un Server Component de forma predeterminada. Se ejecuta en el servidor, tiene acceso directo a bases de datos y sistemas de archivos, y no envía JavaScript al cliente. En el momento en que agregas "use client", ese componente y todo lo que importa pasa a ser un Client Component: se hidrata en el navegador y puede usar estado, efectos y controladores de eventos.
Server Components: datos y diseño
Los Server Components sirven para obtener datos, definir el diseño y mostrar contenido que no necesita interactividad. Se ejecutan una sola vez en el servidor, envían HTML al cliente y nunca vuelven a renderizarse en el navegador.
// app/blog/[slug]/page.tsx — Server Component (default)
import { db } from "@/lib/database";
import { formatDate } from "@/utils/dates";
import { CommentSection } from "./CommentSection"; // Client Component
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
// Direct database access — no API route needed
const post = await db.posts.findUnique({
where: { slug },
include: { author: true },
});
if (!post) notFound();
return (
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt.toISOString()}>
{formatDate(post.publishedAt)}
</time>
<span>By {post.author.name}</span>
</header>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{/* Client boundary starts here */}
<CommentSection postId={post.id} />
</article>
);
}Client Components: interactividad
Los Client Components son para todo lo que necesite APIs del navegador, estado, efectos o controladores de eventos. Márcalos con "use client" al principio del archivo.
// ❌ Making an entire page a Client Component for one button
"use client";
export default function ProductPage({ product }) {
const [added, setAdded] = useState(false);
// Now ALL data fetching must happen client-side
// Page ships unnecessary JavaScript
// No direct database access
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<button onClick={() => setAdded(true)}>Add to Cart</button>
</div>
);
}
// ✅ Only the interactive part is a Client Component
// app/products/[id]/page.tsx — Server Component
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}
// components/AddToCartButton.tsx — Client Component
"use client";
import { useState } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
async function handleAdd() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId }),
});
setAdded(true);
}
return (
<button onClick={handleAdd} disabled={added}>
{added ? "Added ✓" : "Add to Cart"}
</button>
);
}El límite de serialización
Las props que pasan de un Server Component a un Client Component deben ser serializables: solo valores compatibles con JSON. Las funciones, clases, objetos Date y Map no pueden cruzar ese límite.
// ❌ Passing non-serializable props
// Server Component
export default async function Dashboard() {
const data = await getMetrics();
return (
<MetricsChart
data={data}
onRefresh={async () => { await refreshMetrics(); }} // Functions can't serialize
formatter={new Intl.NumberFormat("en-US")} // Classes can't serialize
/>
);
}
// ✅ Pass serializable data, let client create its own functions
export default async function Dashboard() {
const data = await getMetrics();
return (
<MetricsChart
data={data.map((d) => ({
label: d.name,
value: d.value,
timestamp: d.timestamp.toISOString(), // Date → string
}))}
locale="en-US" // String, not Intl object
/>
);
}Patrones de composición: servidor dentro de cliente
Un Client Component no puede importar un Server Component directamente. Pero sí puede recibirlo como children o como prop; este es el patrón de slot.
// ✅ Server Component passed as children to Client Component
// layout.tsx (Server Component)
import { Sidebar } from "./Sidebar"; // Client Component
import { UserProfile } from "./UserProfile"; // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar>
{/* Server Component rendered as children of Client Component */}
<UserProfile />
</Sidebar>
<main>{children}</main>
</div>
);
}
// Sidebar.tsx (Client Component)
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>
{collapsed ? "→" : "←"}
</button>
{!collapsed && children}
</aside>
);
}Server Actions para mutaciones
Las Server Actions permiten que los Client Components llamen directamente a funciones del lado del servidor. Sustituyen a las rutas de API para el envío de formularios y las mutaciones.
// actions/cart.ts
"use server";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
export async function addToCart(productId: string): Promise<{
success: boolean;
error?: string;
}> {
const session = await getSession(cookies());
if (!session) {
return { success: false, error: "Not authenticated" };
}
await db.cartItems.create({
data: {
userId: session.userId,
productId,
quantity: 1,
},
});
revalidatePath("/cart");
return { success: true };
}
// Client Component using the Server Action
"use client";
import { addToCart } from "@/actions/cart";
import { useTransition } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
const result = await addToCart(productId);
if (!result.success) {
console.error(result.error);
}
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Adding..." : "Add to Cart"}
</button>
);
}El marco de decisión
Usa esta lista de verificación para decidir dónde debe renderizarse un componente.
interface ComponentDecision {
needsState: boolean; // useState, useReducer
needsEffects: boolean; // useEffect, useLayoutEffect
needsEventHandlers: boolean; // onClick, onChange, onSubmit
needsBrowserAPIs: boolean; // window, document, localStorage
fetchesData: boolean; // Database queries, file reads
displaysStaticContent: boolean;
hasExpensiveImports: boolean; // Heavy libraries (charts, editors)
}
function shouldBeClientComponent(decision: ComponentDecision): boolean {
return (
decision.needsState ||
decision.needsEffects ||
decision.needsEventHandlers ||
decision.needsBrowserAPIs
);
}
// If shouldBeClientComponent is false → Server Component
// If true → make the SMALLEST possible component a Client Component
// Keep data fetching and layout in Server ComponentsConclusiones clave
Por defecto, usa Server Components. Obtienen datos directamente, no envían JavaScript y renderizan más rápido. Agrega "use client" solo cuando necesites estado, efectos, controladores de eventos o APIs del navegador, y haz que el Client Component sea lo más pequeño posible.
Las props que cruzan el límite entre servidor y cliente deben ser serializables. Usa el patrón de children/slot para anidar Server Components dentro de Client Components. Las Server Actions sustituyen a las rutas de API en las mutaciones, manteniendo la lógica del servidor junto al componente que la dispara. El objetivo es empujar el límite del cliente lo más abajo posible en el árbol de componentes, dejando el renderizado costoso en el servidor y enviando solo el JavaScript interactivo que el usuario realmente necesita.


