Dibujar correctamente el límite ‘use client’ en Next.js App Router
Aprenda qué marca realmente la directiva ‘use client’, por qué marcar contenedores hace que los paquetes se vuelvan más grandes, y cómo los componentes de nivel inferior y las ranuras de hijos mantienen el trabajo del servidor en el propio servidor.
Al abrir muchos proyectos basados en App Router, encontrarás 'use client' al principio de casi cada componente. Este hábito se forma rápidamente: añades un onClick o un useState para un menú desplegable, la compilación genera errores, y la directiva hace que esos errores desaparezcan. Al repetir esto unas cuantas docenas de veces, todo el código termina incluido en el paquete del navegador, lo que resulta en una aplicación de página única con demasiadas complejidades. Esta guía explica qué hace realmente la directiva y cómo colocarla para que el trabajo relacionado con el servidor permanezca allí, y solo las partes verdaderamente interactivas envíen JavaScript.
Qué marca realmente la directiva
El nombre sugiere “ejecutar esto solo en el navegador”, pero ese no es su significado. 'use client' declara un límite de módulo: el archivo donde termina el grafo de módulos del servidor y comienza el paquete del cliente. Los componentes del cliente siguen siendo pre-renderizados a HTML en el servidor; además, se activan en el navegador.
[ Server Component Tree ] (Executes solely on the server; zero KB client JS)
│
├── Server Component A (Fetches directly from DB)
│
▼
── [ 'use client' Boundary ] ──
│
├── Client Component B (Pre-rendered to HTML on server, hydrated on client)
│ │
│ └── Regular Component C (Now forced into the client bundle!)
La consecuencia que dificulta el trabajo a los equipos es transitiva. Cada módulo importado por un archivo marcado con 'use client' también forma parte del paquete del cliente, independientemente de que contenga o no dicha directiva. Si el diseño de un panel principal se marca como módulo del cliente porque contiene un menú desplegable con perfiles, entonces su barra lateral, ventanas modales, herramientas y todas las bibliotecas pesadas que importe también se envían al navegador. Para conocer con más detalle por qué el código exclusivo para servidor no ocupa ningún byte en el paquete, consulte cómo React Server Components logra un renderizado sin paquete.
La trampa del contenedor interactivo
El error más frecuente es convertir toda una página en un Componente de Cliente solo porque una pequeña parte de ella es interactiva. El ejemplo a continuación necesita estado únicamente para un interruptor de filtro, pero todo el panel de control se marca como código de cliente:
// ❌ BAD: The entire page is pulled into the client bundle
'use client';
import { useState, useEffect } from 'react';
import { db } from '@/lib/db'; // 🚨 Build error or massive security/bundle hazard!
export default function UserDashboard() {
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
// You've recreated client-side waterfall fetching
fetch('/api/user-data').then(res => res.json()).then(setData);
}, []);
return (
<div className="p-8">
<button onClick={() => setIsOpen(!isOpen)}>Toggle Filter</button>
{isOpen && <div className="dropdown">...</div>}
<div className="grid">
{/* Render heavy data tables */}
</div>
</div>
);
}
Surgen dos problemas. Importar un cliente de base de datos en un módulo de cliente o bien hace que la compilación falle, o, peor aún, corre el riesgo de llevar código y configuraciones exclusivas del servidor al navegador. Además, la carga de datos se realiza dentro de useEffect, por lo que la página se renderiza vacía y luego solicita datos después de la hidratación, recreando así el flujo en cascada del lado del cliente que los Componentes de Servidor estaban destinados a eliminar. Al agregar el paquete server-only a módulos como @/lib/db, ese primer error se convierte en un error de compilación explícito.
Desplazar la interactividad a los elementos más periféricos
El estado debe estar en el componente más pequeño que lo necesite. Aquí, el botón de alternancia se convierte en su propio módulo cliente, y todo lo que muestra se pasa como children:
// components/FilterToggle.tsx
'use client';
import { useState } from 'react';
export function FilterToggle({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-3 py-1.5 bg-neutral-100 rounded text-sm"
>
{isOpen ? 'Hide Filters' : 'Show Filters'}
</button>
{isOpen && <div className="mt-2">{children}</div>}
</div>
);
}
La página sigue siendo un componente servidor asíncrono. Consulta directamente la base de datos, renderiza marcado estático e incrusta el botón de alternancia solo donde ocurre la interacción:
// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db';
import { FilterToggle } from '@/components/FilterToggle';
import { AnalyticsChart } from '@/components/AnalyticsChart';
export default async function UserDashboard() {
// Direct DB access — zero client-side fetch waterfalls
const metrics = await db.metrics.findMany();
return (
<main className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold">Performance Analytics</h1>
<FilterToggle>
<p className="text-sm text-neutral-500">Filter options...</p>
</FilterToggle>
</div>
<div className="grid grid-cols-3 gap-4">
{/* AnalyticsChart can also remain an RSC if it doesn't need canvas/DOM APIs */}
<AnalyticsChart data={metrics} />
</div>
</main>
);
}
El párrafo pasado a FilterToggle se renderiza en el servidor, aunque aparezca dentro de un componente cliente. AnalyticsChart puede seguir siendo un componente servidor siempre y cuando no dependa de APIs exclusivas del navegador como canvas; si lo hace, solo ese gráfico necesita la directiva.
Componer contenido de servidor dentro de estructuras cliente
La misma técnica se puede aplicar a los diseños. Supongamos un componente cliente CollapsibleShell que gestiona el estado abierto y cerrado, de forma similar a FilterToggle. El diseño, que es un componente servidor, crea el feed costoso y se lo entrega al shell como una ranura:
// app/layout.tsx
import { CollapsibleShell } from '@/components/CollapsibleShell';
import { ExpensiveServerFeed } from '@/components/ExpensiveServerFeed';
export default function RootLayout() {
return (
<html lang="en">
<body>
<CollapsibleShell>
{/* ExpensiveServerFeed runs on the server, streams HTML, and ships 0kb JS */}
<ExpensiveServerFeed />
</CollapsibleShell>
</body>
</html>
);
}
Dado que ExpensiveServerFeed se crea en un contexto servidor y se pasa como children, se renderiza completamente en el servidor. React envía su salida renderizada a través de la frontera en el payload RSC, y el código del componente nunca entra en el paquete del cliente. La diferencia clave: un módulo cliente que importa un componente lo incluye en el paquete, mientras que un componente cliente que recibe elementos ya renderizados como propiedades no lo hace.
Una lista de verificación antes de agregar la directiva
- ¿Este componente utiliza estado, efectos, manejadores de eventos o APIs del navegador? Si no, déjelo en el servidor.
- ¿Se puede extraer la parte interactiva a un componente hijo más pequeño?
- ¿Se puede pasar el contenido renderizado en el servidor a través de
childrenu otra propiedad en lugar de importarlo? - ¿Son todos los parámetros que cruzan el límite serializables, sin funciones ni instancias de clases excepto Server Actions?
- ¿Markear este archivo implicaría incluir una biblioteca pesada, como un analizador de Markdown o herramientas de fechas, que podría permanecer en el servidor?
Puntos clave
- Los componentes del servidor son la opción por defecto por una razón: mantenga allí la obtención de datos, las herramientas de análisis y el marcado estático.
- Aíslen islas interactivas pequeñas en lugar de convertir sus contenedores.
children y otras propiedades de slot para envolver la salida del servidor en comportamiento del cliente sin enviarla.'use client' como un límite arquitectónico intencional, no como una forma de silenciar un error de compilación. Si se hace bien, los paquetes permanecen pequeños y el contenido se muestra de inmediato una vez cargado.Lecturas relacionadas
- Construyendo una página de detalle de películas resiliente con Next.js App Router — Aprenda cómo obtener y almacenar en caché los datos de la API OMDB correctamente en Next.js App Router utilizando componentes servidores asíncronos, parámetros esperados y un manejo adecuado de errores 404.
- Migrar de next/router: Params, URLs Shallow y 404s en App Router — Descubre cómo funcionan los params, useParams, las actualizaciones de URLs Shallow, la navegación programática y las páginas 404 reales en el App Router de Next.js una vez que next/router ya no esté disponible.
- Fetch paralelo en React: Suspense con Promise.all y allSettled — Aprende cómo eliminar las secuencias de solicitudes con Promise.all, evitar que los datos opcionales dañen las páginas con Promise.allSettled y mostrar estados de carga con Suspense.