Server Components en Next.js: qué datos mover al servidor y qué sigue necesitando un client component
Cuando migramos un proyecto de Pages Router a App Router, el error más común que vemos no es olvidar poner 'use client' donde hace falta. Es ponerlo donde no hace falta. Un componente que en Pages Router vivía entero en el cliente porque no había otra opción se copia tal cual a App Router, alguien le agrega 'use client' arriba para que compile, y ahí se queda: convertido en client component completo cuando la mitad de su lógica -fetch de datos, formateo, cálculo de totales- podía resolverse en el servidor sin tocar un solo evento del navegador.
Esto no es una introducción a Server Components. Es la lista de criterios que usamos para decidir, componente por componente, qué se queda en el servidor y qué necesita cruzar la frontera hacia el cliente.
El criterio no es "dónde vive el dato", es "qué hace el componente con él"
La pregunta que de verdad importa no es si un componente necesita datos -casi todos los necesitan- sino si ese componente necesita reaccionar a algo que solo existe en el navegador: un clic, un cambio de input, el scroll, window, localStorage, una suscripción con useEffect. Si la respuesta es no, el componente puede ser server component sin importar cuántos datos maneje.
// app/productos/[id]/page.tsx
export default async function ProductoPage({ params }: { params: { id: string } }) {
const producto = await obtenerProducto(params.id);
const relacionados = await obtenerRelacionados(producto.categoriaId);
return (
<div>
<h1>{producto.nombre}</h1>
<p>{producto.descripcion}</p>
<ListaRelacionados productos={relacionados} />
</div>
);
}
Este componente hace dos llamadas a datos, las resuelve en el servidor, y nunca necesita re-renderizarse por interacción del usuario. No hay ninguna razón para que sea client component, y convertirlo en uno obligaría a exponer obtenerProducto como endpoint de API, mandar el fetch por el navegador, y aceptar el salto en el tiempo de carga que eso implica sin ninguna ganancia a cambio.
Dónde sí hace falta la frontera
El error contrario también existe: dejar todo como server component y toparse con que React tira un error en build porque useState no puede ejecutarse ahí. La regla es simple una vez que se identifica: cualquier hook de React que dependa de estado o ciclo de vida del cliente (useState, useEffect, useReducer, useContext con un provider que vive en el cliente), cualquier manejador de evento (onClick, onChange, onSubmit) y cualquier API del navegador exige 'use client'.
// components/galeria-producto.tsx
'use client';
import { useState } from 'react';
export function GaleriaProducto({ imagenes }: { imagenes: string[] }) {
const [indice, setIndice] = useState(0);
return (
<div>
<img src={imagenes[indice]} alt="" />
<div>
{imagenes.map((_, i) => (
<button key={i} onClick={() => setIndice(i)}>
{i + 1}
</button>
))}
</div>
</div>
);
}
La clave de este ejemplo es que GaleriaProducto no hace fetch de nada. Recibe imagenes como prop, ya resuelto por el server component padre. El límite entre servidor y cliente no está en "esta pantalla necesita interacción", está en "este pedazo específico de la pantalla necesita interacción". El resto de la página -título, descripción, productos relacionados- sigue siendo servidor.
El patrón que evita que un solo botón vuelva client toda una página
El error más caro que vemos repetirse es poner 'use client' en el componente de más arriba porque en algún punto del árbol hay un botón con onClick. React marca client a partir de ahí hacia abajo salvo que se rompa esa cadena explícitamente, así que si el 'use client' queda en page.tsx, toda la página -incluyendo las partes que solo leen datos y las renderizan- termina ejecutándose en el cliente, con su bundle de JavaScript correspondiente viajando al navegador aunque nueve de cada diez componentes de esa página nunca reaccionen a nada.
La forma de romper esa cadena es el patrón de composición con children: un client component puede recibir server components como children sin que estos se conviertan en client, porque lo que cruza la frontera es el árbol ya renderizado, no el código fuente.
// components/menu-desplegable.tsx
'use client';
import { useState } from 'react';
export function MenuDesplegable({ children }: { children: React.ReactNode }) {
const [abierto, setAbierto] = useState(false);
return (
<div>
<button onClick={() => setAbierto(!abierto)}>Menú</button>
{abierto && <div>{children}</div>}
</div>
);
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const notificaciones = await obtenerNotificaciones();
return (
<MenuDesplegable>
<ListaNotificaciones notificaciones={notificaciones} />
</MenuDesplegable>
);
}
ListaNotificaciones sigue siendo server component. obtenerNotificaciones se resuelve en el servidor, sin exponer ningún endpoint nuevo, y el único JavaScript que viaja al navegador es el necesario para abrir y cerrar el menú. Este patrón es el que más repetimos en revisiones de código: cuando alguien pregunta "¿por qué esta página entera es client component si solo hay un acordeón?", casi siempre la respuesta es que nadie separó el acordeón del contenido que va adentro.
El error que sí cometimos: mover fetching al cliente por una razón que no aplicaba
Durante la migración de un panel administrativo intentamos mantener parte del fetching de datos en el cliente, con una librería de fetching con cache, argumentando que así los datos se refrescarían solos cuando el usuario volviera a la pestaña. En la práctica, esto introdujo una cascada: la página cargaba vacía, el componente cliente montaba, disparaba el fetch, y recién ahí aparecían los datos, agregando un salto de red completo que en Server Components no existe porque el HTML ya llega con los datos resueltos.
Revertimos esa parte: los datos que se muestran al cargar la página volvieron a resolverse en el servidor. Donde sí seguía haciendo falta refresco en vivo -un contador de notificaciones sin leer que cambia mientras el usuario está en la pantalla- dejamos un client component pequeño y aislado, con su propio fetching, en vez de mover toda la sección de vuelta al cliente. La lección no fue "el fetching en cliente está mal", fue que el criterio para usarlo es la necesidad real de actualización en vivo, no una preferencia general por mantener los datos "frescos" cuando el usuario ya los recibe frescos en el primer render del servidor.
Errores que sí se repiten en revisiones de código
El primero es pasar funciones desde un server component a un client component como si fueran props normales. No funciona: una función no es serializable a través de la frontera servidor-cliente, salvo que sea explícitamente una Server Action marcada con 'use server'. El error de build en este caso es razonablemente claro, pero cuesta tiempo si no se conoce la causa.
El segundo es usar useContext en un componente que nunca necesitó 'use client' por ninguna otra razón, solo para leer un tema o una configuración que en realidad podía resolverse leyendo una cookie o un header en el servidor con cookies() o headers() de next/headers. Cada vez que un contexto de React aparece en un componente que de otra forma sería servidor, vale la pena preguntar si ese dato realmente cambia durante la sesión del usuario en el cliente o si es una configuración que ya se conoce en el momento del request.
El tercero es duplicar la validación: server actions ya corren en el servidor, así que la validación de un formulario no necesita estado de cliente para existir, solo para dar feedback inmediato mientras el usuario escribe. Cuando esa validación inmediata no es un requisito real de producto, todo el formulario puede resolverse como server component con una Server Action, sin useState de por medio.
El criterio resumido
Server component por defecto. Client component solo en el nodo específico del árbol que necesita estado de interacción, un evento del navegador o una API que no existe en el servidor. Los datos se resuelven donde se piden, no donde se muestran: un client component recibe datos ya resueltos como props en vez de volver a pedirlos por su cuenta, salvo que exista una necesidad real de refresco en vivo. Y cuando aparece un componente interactivo dentro de una pantalla mayormente estática, el patrón de children casi siempre evita que esa pieza interactiva arrastre al resto de la página al cliente.