Server Component y Client Component en Next.js
1. Visión general
Next.js App Router renderiza componentes en servidor por defecto, a diferencia del modelo “todo es cliente” del Pages Router anterior. Este artículo te ayuda a entender en qué se diferencia un Server Component de un Client Component, cuándo es obligatorio usar "use client", y cómo combinar estos dos tipos de componentes en el mismo árbol de renderizado sin errores. Después de leerlo, podrás decidir por ti mismo si un componente debe estar en el servidor o en el cliente, y sabrás cómo pasar datos correctamente a través de ese límite.
2. ¿Qué es un Server Component?
Un Server Component es un tipo de componente por defecto en App Router (carpeta app/). Se ejecuta completamente en el servidor y no envía su propio JavaScript al navegador.
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: number; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Algunos puntos importantes a tener en cuenta:
ProductsPagees unaasync function— Los Server Components pueden usarawaitdirectamente sin necesidad deuseEffectniuseStatepara obtener datos.- El comando
fetchse ejecuta en el servidor, por lo que las claves API y la lógica sensible nunca se exponen al navegador. - No hay una línea
"use client"al inicio del archivo, así que Next.js entiende automáticamente que es un Server Component.
2.1. Por qué se recomienda usar Server Component
Los Server Components ayudan a reducir el tamaño del bundle JavaScript que se envía al cliente, ya que el componente mismo (y sus imports, si no se convierten a cliente) no se empaqueta en el bundle del navegador. También puede acceder directamente a la base de datos, al sistema de archivos o a variables de entorno secretas sin necesidad de exponerlas a través de una ruta API.
Request → Server render (fetch, DB query) → HTML + RSC payload → Browser
El navegador solo recibe HTML pre-renderizado junto con un payload que describe el árbol de componentes (RSC payload), no recibe el código fuente JS del Server Component.
3. ¿Qué es un Client Component?
Un Client Component es un componente que necesita ejecutarse en el navegador — para usar state, efectos, manejadores de eventos o APIs web como localStorage o window. Lo declaras con la directiva "use client" al inicio del archivo.
// app/components/LikeButton.tsx
"use client";
import { useState } from "react";
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️ Me gusta" : "🤍 Marcar"}
</button>
);
}
Explicación:
"use client"debe ser la primera línea del archivo (antes incluso de los imports), marca un límite — todo lo importado de este archivo también se empaqueta para el cliente.useStatesolo funciona en Client Components porque necesita JavaScript ejecutándose en el navegador para manejar re-renders.onClickes un manejador de eventos — una señal segura de que necesitas un Client Component.
Nota:
"use client"no significa que el componente solo se ejecute en el cliente. Next.js aún lo renderiza la primera vez en el servidor (SSR) para obtener HTML rápidamente, luego lo “hidrata” en el navegador para hacerlo interactivo.
4. Cuándo es obligatorio usar Client Component
Hay varios casos en los que debes agregar "use client", de lo contrario obtendrás errores en la compilación o en tiempo de ejecución:
Error: useState can not be used in Server Components.
Estos casos incluyen:
- Usar React hooks:
useState,useEffect,useReducer,useContext… - Adjuntar manejadores de eventos:
onClick,onChange,onSubmit… - Usar APIs web solo disponibles en el navegador:
window,document,localStorage. - Usar custom hooks que contienen alguno de los anteriores.
- Usar librerías que solo se ejecutan en el cliente (por ejemplo, algunas librerías de gráficos que dependen de
window).
Por el contrario, si un componente solo recibe props y renderiza JSX puro sin hooks ni eventos — déjalo como Server Component, incluso si está anidado profundamente en un árbol con otros Client Components.
5. Combinando Server Component y Client Component
Lo mejor de este modelo es que puedes anidar un Server Component dentro de un Client Component a través de children (composición), en lugar de importarlo directamente.
// app/components/Modal.tsx
"use client";
import { useState } from "react";
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Abrir detalles</button>
{open && <div className="modal">{children}</div>}
</>
);
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";
async function getOrder(id: string) {
const res = await fetch(`https://api.shop.local/orders/${id}`);
return res.json();
}
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await getOrder(params.id);
return (
<Modal>
{/* OrderDetail sigue siendo un Server Component, solo se "pasa" como children */}
<p>ID de pedido: {order.id}</p>
<p>Monto total: {order.total.toLocaleString("es-ES")} €</p>
</Modal>
);
}
Explicación del mecanismo:
Modales un Client Component que maneja el estado de abierto/cerrado.- El contenido dentro (
<p>ID de pedido...) se renderiza enOrderPage— un Server Component — y luego se pasa aModalcomochildren. - Next.js renderiza esta parte
childrenen el servidor primero, luego “inserta” el resultado en la posición{children}del Client Component durante la hidratación. De esta forma,getOrderno necesita ser convertido a cliente.
Nota: No puedes importar directamente un Server Component
asyncy usarlo dentro de un Client Component (por ejemploimport OrderDetail from "./OrderDetail"en un archivo con"use client"). Next.js considerará queOrderDetailestá siendo incluido en el bundle del cliente y mostrará un error porque unaasync function componentno es válida en el cliente. La composición a través dechildrenopropses la forma correcta.
6. Pasar datos de Server Component a Client Component
Las props que se pasan de un Server Component a un Client Component deben ser serializables — es decir, convertibles a JSON. No puedes pasar funciones, instancias de clases u objetos Date complejos.
// app/components/PriceTag.tsx
"use client";
export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
return <span>{amount.toLocaleString("es-ES")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<PriceTag amount={product.price} currency="EUR" />
</div>
);
}
Aquí amount (número) y currency (cadena) son datos primitivos, serializables. Si intentas pasar una función callback de un Server Component a PriceTag, Next.js mostrará un error durante la compilación o en tiempo de ejecución:
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".
Para llamar lógica del servidor desde el cliente (por ejemplo, enviar un formulario), usa una Server Action — marcada con "use server" — en lugar de pasar una función ordinaria.
7. Server Action — El puente de vuelta
Cuando un Client Component necesita llamar nuevamente al servidor (escribir datos, consultar la base de datos), usa una Server Action en lugar de crear una ruta API separada.
// app/actions/order-actions.ts
"use server";
export async function cancelOrder(orderId: string) {
await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";
import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";
export default function CancelButton({ orderId }: { orderId: string }) {
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
await cancelOrder(orderId);
setPending(false);
}
return (
<button onClick={handleClick} disabled={pending}>
{pending ? "Cancelando..." : "Cancelar pedido"}
</button>
);
}
cancelOrderestá marcada como"use server", Next.js genera automáticamente un endpoint oculto para que el Client Component lo llame sin necesidad de que escribas un manejador de ruta.CancelButtonlo importa y lo llama como una función normal — el código se lee naturalmente aunque en realidad es una llamada de red.
8. Escribir pruebas para lógica separada
Dado que los Server Components a menudo contienen lógica de obtención/transformación de datos, separar la lógica pura (sin JSX) hace que sea más fácil de probar que probar todo el componente renderizado.
// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
if (amount < 0) {
throw new Error("el monto no puede ser negativo");
}
return `${amount.toLocaleString("es-ES")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";
describe("formatPrice", () => {
it("formatea correctamente un monto válido", () => {
expect(formatPrice(150000, "EUR")).toBe("150.000 EUR");
});
it("lanza un error cuando el monto es negativo", () => {
expect(() => formatPrice(-1, "EUR")).toThrow("el monto no puede ser negativo");
});
});
Si tu proyecto usa Jest + Testing Library para la parte de Client Component (por ejemplo LikeButton), pruebas como un componente React normal — Jest no necesita saber nada sobre "use client", ya que esa directiva solo tiene significado para el bundler de Next.js, no afecta al runtime de las pruebas.
// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";
describe("LikeButton", () => {
it("cambia de estado cuando se hace clic", () => {
render(<LikeButton />);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Marcar");
fireEvent.click(button);
expect(button).toHaveTextContent("Me gusta");
});
});
9. Errores comunes
Algunos errores comunes al cambiar al App Router:
- Olvidar
"use client"al usar hooks: erroruseState can not be used in Server Components— agrega la directiva al inicio del archivo que contiene el hook, no al archivo que lo llama. - Colocar
"use client"demasiado alto en el árbol de componentes: si marcas todolayout.tsxcomo cliente, todo adentro (incluso Server Components secundarios) también se incluye en el bundle del cliente si se importa directamente, haciendo el bundle innecesariamente grande. Solo debes convertir a cliente el componente más pequeño que lo necesita (por ejemplo, un botón, un input de formulario), no la página completa. - Pasar una función u objeto no serializable como props de un Server Component a un Client Component — usa Server Action (
"use server") en lugar de pasar un callback. - Importar un Server Component dentro de un archivo
"use client"esperando que aún se ejecute en el servidor — no es posible, debes usar composición a través dechildren/props. - Llamar a
fetchcon una clave secreta en un Client Component — la clave API se expondrá en el bundle JS enviado al navegador. Las operaciones que requieren secretos siempre deben estar en un Server Component o Server Action.
Nota: No es seguro simplemente “convertir todo a Client Component para que sea como el código antiguo”. Cada Client Component innecesario aumenta el tamaño del bundle, ralentiza el tiempo de interacción (TTI) y pierde los beneficios del renderizado en servidor.
10. Conclusión
Los Server Components son la opción predeterminada en App Router, ideales para obtener datos, acceder a recursos sensibles y reducir el bundle JS. Los Client Components solo deben usarse cuando hay state, efectos o manejadores de eventos. Estos dos tipos se combinan a través de composición (children, props serializables) y Server Action es el puente para que un Client Component llame nuevamente a la lógica del servidor. Dominar este límite te ayuda a optimizar el tamaño del bundle y mantener los datos sensibles seguros en el servidor.