Saltar al contenido

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.

4 min de lectura
Un árbol de componentes dividido entre los límites de servidor y cliente, que muestra el flujo de datos y las ubicaciones de renderizado

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.

tsxtsx
// 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.

tsxtsx
// ❌ 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.

tsxtsx
// ❌ 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.

tsxtsx
// ✅ 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.

tsxtsx
// 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.

tstypescript
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 Components

Conclusiones 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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX