Desempeño de Server Components — Reduce JavaScript del cliente en 30-70% con React Server Components
Receta
// Server Component (predeterminado en App Router de Next.js) — cero JavaScript del cliente
// app/products/page.tsx
import { ProductFilters } from "./ProductFilters"; // componente de cliente
import { db } from "@/lib/db";
export default async function ProductsPage() {
// La obtención de datos ocurre en el servidor — sin fetch(), sin estado de carga, sin JavaScript del cliente
const products = await db.product.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
return (
<div>
<h1>Productos</h1>
{/* Solo este pequeño componente envía JS al cliente */}
<ProductFilters />
{/* Lista renderizada en el servidor — cero JavaScript del cliente */}
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — ${p.price}
</li>
))}
</ul>
</div>
);
}
// ProductFilters.tsx — pequeño límite de "use client"
"use client";
import { useState } from "react";
export function ProductFilters() {
const [category, setCategory] = useState("all");
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">Todos</option>
<option value="electronics">Electrónica</option>
<option value="clothing">Ropa</option>
</select>
);
}Cuándo usarlo: Para cada componente de forma predeterminada. Comienza con Server Components y solo añade "use client" cuando el componente necesita interactividad (useState, useEffect, manejadores de eventos, APIs del navegador).
Ejemplo de funcionamiento
// ---- ANTES: Página pesada con cliente — 245KB de JavaScript del cliente comprimido ----
// app/dashboard/page.tsx
"use client"; // ¡Toda la página es un componente de cliente!
import { useState, useEffect } from "react";
import { format } from "date-fns";
import { BarChart, Bar, XAxis, YAxis } from "recharts";
interface DashboardData {
revenue: number;
orders: number;
customers: number;
recentOrders: Order[];
chartData: ChartPoint[];
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/dashboard")
.then((res) => res.json())
.then((d) => {
setData(d);
setLoading(false);
});
}, []);
if (loading) return <div>Cargando...</div>;
if (!data) return <div>Error</div>;
return (
<div className="grid grid-cols-3 gap-6">
{/* Tarjetas de estadísticas — podrían ser HTML estático */}
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Ingresos</p>
<p className="text-2xl font-bold">${data.revenue.toLocaleString()}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Pedidos</p>
<p className="text-2xl font-bold">{data.orders}</p>
</div>
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">Clientes</p>
<p className="text-2xl font-bold">{data.customers}</p>
</div>
{/* Gráfico — necesita JavaScript del cliente pero solo 1 sección */}
<div className="col-span-2">
<BarChart width={600} height={300} data={data.chartData}>
<XAxis dataKey="month" />
<YAxis />
<Bar dataKey="revenue" fill="#3b82f6" />
</BarChart>
</div>
{/* Lista de pedidos — podrían ser HTML estático */}
<div>
<h2>Pedidos Recientes</h2>
<ul>
{data.recentOrders.map((order) => (
<li key={order.id}>
{order.customer} — ${order.total}
<span className="text-gray-400 text-sm">
{format(new Date(order.date), "MMM dd")}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
// ---- DESPUÉS: Primero el servidor — 52KB de JavaScript del cliente comprimido (79% de reducción) ----
// app/dashboard/page.tsx — Server Component (predeterminado)
import { Suspense } from "react";
import { format } from "date-fns";
import { db } from "@/lib/db";
import { RevenueChart } from "./RevenueChart"; // solo componente de cliente
export default async function DashboardPage() {
// Obtención de datos del lado del servidor — cero JavaScript del cliente, sin estado de carga necesario
const [stats, recentOrders] = await Promise.all([
db.stats.findFirst(),
db.order.findMany({ orderBy: { date: "desc" }, take: 10 }),
]);
return (
<div className="grid grid-cols-3 gap-6">
{/* Tarjetas de estadísticas — HTML puro del servidor, cero JS */}
<StatsCard label="Ingresos" value={`$${stats.revenue.toLocaleString()}`} />
<StatsCard label="Pedidos" value={stats.orders.toString()} />
<StatsCard label="Clientes" value={stats.customers.toString()} />
{/* Gráfico — solo sección interactiva, envuelta en Suspense */}
<div className="col-span-2">
<Suspense fallback={<div className="h-72 animate-pulse bg-gray-100 rounded" />}>
<RevenueChart />
</Suspense>
</div>
{/* Lista de pedidos — HTML puro del servidor, cero JS */}
<div>
<h2 className="font-semibold mb-2">Pedidos Recientes</h2>
<ul className="space-y-2">
{recentOrders.map((order) => (
<li key={order.id} className="flex justify-between">
<span>
{order.customer} — ${order.total}
</span>
<span className="text-gray-400 text-sm">
{format(order.date, "MMM dd")}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
// Componente puro del servidor — sin "use client", sin JavaScript enviado al navegador
function StatsCard({ label, value }: { label: string; value: string }) {
return (
<div className="p-4 bg-white rounded shadow">
<p className="text-gray-500">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
);
}
// RevenueChart.tsx — límite de cliente más pequeño posible
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
const BarChart = dynamic(
() => import("recharts").then((m) => m.BarChart),
{ ssr: false }
);
const Bar = dynamic(() => import("recharts").then((m) => m.Bar), { ssr: false });
const XAxis = dynamic(() => import("recharts").then((m) => m.XAxis), { ssr: false });
const YAxis = dynamic(() => import("recharts").then((m) => m.YAxis), { ssr: false });
export function RevenueChart() {
const [chartData, setChartData] = useState<ChartPoint[]>([]);
useEffect(() => {
fetch("/api/dashboard/chart")
.then((r) => r.json())
.then(setChartData);
}, []);
if (!chartData.length) return null;
return (
<BarChart width={600} height={300} data={chartData}>
<XAxis dataKey="month" />
<YAxis />
<Bar dataKey="revenue" fill="#3b82f6" />
</BarChart>
);
}Lo que esto demuestra:
- Las tarjetas de estadísticas y la lista de pedidos pasaron del cliente al servidor: cero JS, renderizado instantáneo
date-fnsse ejecuta solo en el servidor: 4KB eliminado del bundle del cliente- Las consultas de base de datos reemplazan las rutas de API: sin fetch del lado del cliente, sin estados de carga/error
- Recharts cargado dinámicamente: 180KB diferido hasta que la sección del gráfico es visible
- Bundle del cliente: 245KB a 52KB comprimido (79% de reducción)
- TTFB mejora porque HTML se transmite con datos reales en lugar de spinners de carga
Inmersión profunda
Cómo funciona
- Los Server Components se renderizan solo en el servidor — Su código (importaciones, lógica, dependencias) nunca se envía al cliente. La salida es un árbol React serializado que el cliente hidrata sin re-ejecutar la lógica del componente.
- La directiva
"use client"crea un límite — Todo lo importado en un archivo"use client"se convierte en parte del bundle del cliente, incluidas sus dependencias. Por eso es crítico mantener el límite lo más pequeño posible. - Los Server Components pueden importar componentes de cliente — Un Server Component puede renderizar un componente
"use client"como hijo. El componente del servidor se renderiza en el servidor; el límite del componente del cliente se serializa e hidrata en el cliente. - Los componentes de cliente no pueden importar server components — Pero los componentes de cliente pueden recibir Server Components como props
children. Este es el patrón de composición clave. - Eliminación de dependencias — Las librerías usadas solo en Server Components (clientes de base de datos, analizadores de markdown, formateadores de fecha, librerías de validación) se excluyen completamente del bundle del cliente.
Variaciones
Server Component envolviendo Client Component (patrón de composición):
// app/layout.tsx — Server Component
import { auth } from "@/lib/auth";
import { Sidebar } from "./Sidebar"; // componente de cliente
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await auth(); // Solo servidor: cero JavaScript del cliente
return (
<div className="flex">
{/* Pasar datos del servidor como props al componente de cliente */}
<Sidebar userName={user.name} role={user.role} />
<main>{children}</main>
</div>
);
}
// Sidebar.tsx — componente de cliente recibe datos del servidor como props serializables
"use client";
import { useState } from "react";
export function Sidebar({ userName, role }: { userName: string; role: string }) {
const [collapsed, setCollapsed] = useState(false);
return (
<nav className={collapsed ? "w-16" : "w-64"}>
<p>{userName} ({role})</p>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
</nav>
);
}Pasar Server Components como children para evitar contaminación de límite del cliente:
// INCORRECTO: Hacer que todo el layout sea un componente de cliente
"use client";
function Layout() {
const [theme, setTheme] = useState("light");
return (
<div className={theme}>
<Header /> {/* Ahora un componente de cliente */}
<Sidebar /> {/* Ahora un componente de cliente */}
<MainContent /> {/* Ahora un componente de cliente — ¡todo JS enviado! */}
</div>
);
}
// CORRECTO: Aislar estado en un wrapper delgado
// ThemeProvider.tsx
"use client";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
return <div className={theme}>{children}</div>;
}
// layout.tsx — Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<Header /> {/* Server Component — cero JS */}
<Sidebar /> {/* Server Component — cero JS */}
{children}
</ThemeProvider>
);
}Notas de TypeScript
- Los Server Components pueden ser funciones
async. TypeScript infiere el tipo de retorno comoPromise<JSX.Element>. - Las props pasadas de Server a Client components deben ser serializables (sin funciones, clases o Dates — usa strings ISO).
- Usa
React.ReactNodepara la propchildrenen componentes wrapper de cliente.
Gotchas
-
Marcar un layout completo como "use client" — Esto fuerza todos los componentes hijos a ser componentes de cliente, incluso si no necesitan interactividad. Un layout con
"use client"puede añadir 100KB+ al bundle del cliente. Solución: Mantén layouts como Server Components. Extrae elementos interactivos (alternancia de tema, estado de navegación) en pequeños componentes de cliente. -
Importar una librería solo del servidor en un componente de cliente — Si un componente de cliente importa
prisma,fsu otros módulos solo del servidor, la compilación falla o toda la librería se agrupa para el cliente. Solución: Mueve importaciones solo del servidor a Server Components y pasa datos como props. -
Pasar props no serializables — Las funciones, objetos
Date,Map/Sete instancias de clase no pueden serializarse a través del límite servidor-cliente. Solución: Convierte a tipos serializables: usa strings ISO para fechas, objetos simples para instancias de clase, y Server Actions para funciones. -
Sobre-dividir en demasiados componentes de cliente — Crear docenas de archivos
"use client"diminutos para cada botón e entrada añade sobrecarga de hidratación y complejidad. Solución: Agrupa elementos interactivos relacionados en un único componente de cliente que represente una sección de UI lógica. -
Asumir que los Server Components están en caché — Los Server Components se re-ejecutan en cada solicitud de forma predeterminada (renderizado dinámico). Solución: Usa
export const revalidate = 3600ogenerateStaticParamspara renderizado estático cuando los datos no cambian por solicitud.
Alternativas
| Enfoque | Compensación |
|---|---|
| Server Components (predeterminado) | Cero JavaScript del cliente; no puedes usar hooks o APIs del navegador |
Componentes "use client" | Interactividad completa; añade al bundle del cliente |
| Importaciones dinámicas | Difiere carga; añade solicitud de red en primer uso |
| Arquitectura de islas (Astro) | Concepto similar; framework diferente |
| Generación estática (SSG) | Pre-renderizado en compilación; sin costo de servidor en tiempo de ejecución |
| Renderizado en borde | Server Components en el borde de CDN; latencia más baja |
FAQs
¿Qué es un React Server Component y por qué mejora el desempeño?
Un Server Component se renderiza solo en el servidor. Su código, importaciones y dependencias nunca se envían al cliente. Esto significa que librerías como formateadores de fecha, clientes de base de datos y analizadores de markdown añaden cero bytes al bundle del cliente.
¿Cuándo deberías añadir "use client" a un componente?
Solo cuando el componente necesita interactividad: useState, useEffect, manejadores de eventos o APIs del navegador. Por defecto usa Server Components y añade "use client" lo más tarde posible en el árbol de componentes.
¿Puede un componente de cliente importar un server component?
No. Los componentes de cliente no pueden importar Server Components directamente. Sin embargo, los componentes de cliente pueden recibir Server Components como props children:
// ThemeProvider.tsx — "use client"
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("light");
return <div className={theme}>{children}</div>;
}Gotcha: ¿Qué sucede si marcas un layout completo como "use client"?
Todos los componentes hijos se convierten en componentes de cliente, incluso si no necesitan interactividad. Esto puede añadir 100KB+ al bundle del cliente. Mantén layouts como Server Components y extrae elementos interactivos en pequeños componentes de cliente.
Gotcha: ¿Qué tipos de props puedes pasar de Server Components a Client Components?
Solo tipos serializables: strings, números, booleanos, arrays, objetos simples y null. Las funciones, objetos Date, Map/Set e instancias de clase no pueden cruzar el límite. Usa strings ISO para fechas y Server Actions para funciones.
¿Cómo pasas datos obtenidos del servidor a un componente de cliente?
Obtén datos en el Server Component y pásalos como props serializables:
// page.tsx (Server Component)
const user = await auth();
return <Sidebar userName={user.name} role={user.role} />;¿Cómo afectan los Server Components a TTFB?
Los Server Components transmiten HTML con datos reales en lugar de shells vacíos con spinners de carga. El navegador recibe contenido significativo en el primer byte, mejorando tanto TTFB como FCP. No se necesita un viaje redondo de fetch del lado del cliente.
¿Los Server Components están en caché de forma predeterminada?
No. Los Server Components se re-ejecutan en cada solicitud de forma predeterminada (renderizado dinámico). Usa export const revalidate = 3600 o generateStaticParams para renderizado estático cuando los datos no cambian por solicitud.
¿Cómo deberías tipificar un Server Component async en TypeScript?
export default async function Page(): Promise<JSX.Element> {
const data = await fetchData();
return <div>{data.name}</div>;
}TypeScript infiere el tipo de retorno como Promise<JSX.Element> automáticamente. Usa React.ReactNode para children en componentes wrapper de cliente.
¿Es malo tener demasiados pequeños componentes "use client"?
Sí. Crear docenas de archivos de cliente diminutos para cada botón e entrada añade sobrecarga de hidratación y complejidad. Agrupa elementos interactivos relacionados en un único componente de cliente que represente una sección de UI lógica.
¿Cómo evitas que una librería solo del servidor se agrupe para el cliente?
Mantén la importación en un Server Component. Si un componente de cliente importa accidentalmente prisma, fs u otros módulos solo del servidor, la compilación falla o toda la librería se agrupa. Mueve importaciones solo del servidor a Server Components y pasa datos como props.
¿Cuál es el patrón de composición para aislar estado de cliente sin contaminar el árbol?
// layout.tsx — Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<Header /> {/* Server Component */}
{children}
</ThemeProvider>
);
}ThemeProvider es "use client" pero recibe children renderizados en el servidor, manteniendo Header como un Server Component con cero JavaScript del cliente.
Relacionado
- Optimización de Bundle — Medir y reducir lo que los Server Components no pueden eliminar
- Suspense y Streaming — Streaming de salida de Server Component para TTFB más rápido
- Desempeño de Obtención de Datos — Patrones de obtención de datos de Server Component
- Caché de Next.js — Caché de salida de Server Component para visitas repetidas