Qué son los React Server Components y por qué importan
Los React Server Components (RSC) representan el cambio más significativo en la arquitectura de React desde la introducción de los hooks. Son componentes que se ejecutan exclusivamente en el servidor: su código nunca se envía al navegador, lo que significa cero kilobytes de JavaScript en el bundle del cliente para esos componentes.
En un modelo tradicional de React, todo el código de tus componentes — incluyendo lógica de transformación de datos, librerías de formateo, validaciones — se envía al cliente aunque solo se ejecute una vez durante el renderizado inicial. Con Server Components, ese código permanece en el servidor y solo el HTML resultante llega al navegador.
// Este componente es un Server Component por defecto en Next.js 15
// Su código NUNCA se envía al navegador
import { formatDistance } from "date-fns";
import { es } from "date-fns/locale";
interface Article {
id: string;
title: string;
content: string;
createdAt: Date;
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Fetch directamente en el componente — sin useEffect, sin useState
const article: Article = await fetch(
`https://api.example.com/articles/${id}`,
{ next: { revalidate: 3600 } }
).then((res) => res.json());
const timeAgo = formatDistance(new Date(article.createdAt), new Date(), {
addSuffix: true,
locale: es,
});
return (
<article className="prose prose-lg max-w-3xl mx-auto">
<h1>{article.title}</h1>
<time className="text-gray-500">{timeAgo}</time>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
);
}
En este ejemplo, la librería date-fns (que pesa más de 70KB) nunca se incluye
en el bundle del cliente. El servidor ejecuta el formateo, genera el HTML y envía solo el
resultado. El usuario recibe una página más ligera y rápida.
Server vs Client Components: cuándo usar cada uno
La decisión entre Server y Client Component depende de lo que necesita hacer el componente. Esta es la regla general:
- Server Component (por defecto en Next.js 15): Para todo lo que no requiere interactividad del usuario ni APIs del navegador.
- Client Component (con
"use client"): Para interactividad, estado, efectos y APIs del navegador.
Cuándo usar Server Components
- Fetching de datos: Acceso directo a bases de datos, APIs o sistemas de archivos.
- Renderizado de contenido: Markdown, HTML, listas estáticas.
- Lógica de negocio pesada: Transformaciones de datos, cálculos, formateo.
- Acceso a secretos: Variables de entorno del servidor, API keys, tokens.
- Componentes de layout: Headers, footers, sidebars sin interactividad.
Cuándo usar Client Components
- Interactividad: onClick, onChange, onSubmit y otros event handlers.
- Estado local: useState, useReducer.
- Efectos: useEffect, useLayoutEffect.
- APIs del navegador: localStorage, window, navigator, IntersectionObserver.
- Hooks personalizados: Cualquier hook que use estado o efectos.
- Librerías de terceros con estado: Formularios (react-hook-form), animaciones (framer-motion).
// Tabla de decisión rápida
// ┌─────────────────────────────┬────────┬────────┐
// │ Necesidad │ Server │ Client │
// ├─────────────────────────────┼────────┼────────┤
// │ Fetch de datos │ ✓ │ │
// │ Acceso a backend directo │ ✓ │ │
// │ Secretos del servidor │ ✓ │ │
// │ Dependencias pesadas │ ✓ │ │
// │ onClick / onChange │ │ ✓ │
// │ useState / useReducer │ │ ✓ │
// │ useEffect │ │ ✓ │
// │ APIs del navegador │ │ ✓ │
// │ Context providers │ │ ✓ │
// │ Class-based components │ │ ✓ │
// └─────────────────────────────┴────────┴────────┘La directiva "use client" y sus límites
La directiva "use client" se coloca al inicio de un archivo para marcar ese
módulo (y todo lo que importa) como código que se ejecuta en el cliente. Es una frontera:
todo lo que esté por debajo de esa directiva en el árbol de importaciones se convierte en
parte del bundle del cliente.
// components/Counter.tsx
"use client"; // Esta línea marca la frontera servidor-cliente
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div className="flex items-center gap-4">
<button
onClick={() => setCount((c) => c - 1)}
className="rounded-lg bg-gray-200 px-4 py-2 hover:bg-gray-300"
aria-label="Decrementar"
>
-
</button>
<span className="text-2xl font-bold tabular-nums">{count}</span>
<button
onClick={() => setCount((c) => c + 1)}
className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
aria-label="Incrementar"
>
+
</button>
</div>
);
}Puntos importantes sobre la directiva:
- Es una frontera de módulo: Afecta al archivo donde se declara y a todos los módulos que ese archivo importa. No puedes tener un Server Component importando directamente desde un archivo marcado con
"use client"y esperar que se ejecute en el servidor. - No "infecta" hacia arriba: Un Server Component puede renderizar un Client Component como hijo. La directiva solo afecta hacia abajo en el árbol de importaciones.
- Debe ser la primera instrucción: La directiva debe ser literalmente la primera línea del archivo (excluyendo comentarios).
Error común: Poner
"use client"en un archivo de layout o en un componente padre de alto nivel. Esto convierte toda la rama en Client Components, perdiendo todos los beneficios de RSC. La directiva debe estar lo más abajo posible en el árbol de componentes.
Fetching de datos en Server Components
Una de las ventajas más poderosas de los Server Components es que pueden ser funciones
async. Puedes usar await directamente en el componente sin
necesidad de useEffect, useState ni librerías de data fetching.
// app/[locale]/projects/page.tsx
// Este componente es async — se ejecuta en el servidor
interface Project {
id: string;
title: string;
description: string;
stack: string[];
url: string;
}
async function getProjects(): Promise<Project[]> {
const res = await fetch("https://api.example.com/projects", {
next: { revalidate: 3600 }, // Revalidar cada hora
});
if (!res.ok) throw new Error("Error al obtener proyectos");
return res.json();
}
export default async function ProjectsPage() {
const projects = await getProjects();
return (
<main className="container mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Proyectos</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<article
key={project.id}
className="rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold">{project.title}</h2>
<p className="mt-2 text-gray-600">{project.description}</p>
<div className="mt-4 flex flex-wrap gap-2">
{project.stack.map((tech) => (
<span
key={tech}
className="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-800"
>
{tech}
</span>
))}
</div>
</article>
))}
</div>
</main>
);
}Acceso directo a la base de datos
Los Server Components pueden acceder directamente a bases de datos sin exponer credenciales ni crear endpoints API intermedios:
// app/[locale]/blog/[slug]/page.tsx
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.post.findUnique({
where: { slug, published: true },
include: {
author: { select: { name: true, avatar: true } },
tags: true,
},
});
if (!post) notFound();
return (
<article className="prose prose-lg max-w-3xl mx-auto py-12">
<header>
<h1>{post.title}</h1>
<div className="flex items-center gap-3 not-prose">
<img
src={post.author.avatar}
alt={post.author.name}
className="h-10 w-10 rounded-full"
/>
<span className="text-gray-600">{post.author.name}</span>
</div>
</header>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Patrones de composición: padre servidor con hijos cliente
El patrón más potente de los RSC es la composición: un Server Component puede renderizar Client Components como hijos, pasándoles datos a través de props. Esto permite mantener la lógica de fetching en el servidor mientras la interactividad se maneja en el cliente.
// app/[locale]/dashboard/page.tsx (Server Component)
import { db } from "@/lib/database";
import { DashboardChart } from "@/components/dashboard/DashboardChart";
import { DashboardFilters } from "@/components/dashboard/DashboardFilters";
import { StatsCards } from "@/components/dashboard/StatsCards";
export default async function DashboardPage() {
// Fetch de datos en el servidor
const [stats, chartData, recentActivity] = await Promise.all([
db.analytics.getStats(),
db.analytics.getChartData({ period: "30d" }),
db.activity.getRecent({ limit: 10 }),
]);
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
{/* StatsCards es un Server Component — solo HTML */}
<StatsCards stats={stats} />
{/* DashboardFilters es un Client Component — tiene estado */}
<DashboardFilters />
{/* DashboardChart es un Client Component — usa una librería de gráficos */}
<DashboardChart data={chartData} />
{/* La lista es un Server Component — sin interactividad */}
<section className="mt-8">
<h2 className="text-xl font-semibold mb-4">Actividad reciente</h2>
<ul className="space-y-3">
{recentActivity.map((item) => (
<li key={item.id} className="rounded-lg border p-4">
<p className="font-medium">{item.description}</p>
<time className="text-sm text-gray-500">{item.timestamp}</time>
</li>
))}
</ul>
</section>
</main>
);
}// components/dashboard/DashboardChart.tsx
"use client";
import { useState } from "react";
interface ChartDataPoint {
date: string;
value: number;
}
interface DashboardChartProps {
data: ChartDataPoint[];
}
export function DashboardChart({ data }: DashboardChartProps) {
const [period, setPeriod] = useState<"7d" | "30d" | "90d">("30d");
// Los datos iniciales vienen del servidor via props
// El estado local maneja la interactividad del cliente
const filteredData = data.filter((point) => {
const daysAgo = period === "7d" ? 7 : period === "30d" ? 30 : 90;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysAgo);
return new Date(point.date) >= cutoff;
});
return (
<div className="rounded-xl border p-6">
<div className="flex gap-2 mb-4">
{(["7d", "30d", "90d"] as const).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={
period === p
? "bg-primary-600 text-white rounded-lg px-3 py-1"
: "bg-gray-100 rounded-lg px-3 py-1 hover:bg-gray-200"
}
>
{p}
</button>
))}
</div>
{/* Renderizar el gráfico con los datos filtrados */}
<div className="h-64 flex items-end gap-1">
{filteredData.map((point) => (
<div
key={point.date}
className="bg-primary-500 rounded-t flex-1 min-w-[4px]"
style={{ height: `${(point.value / Math.max(...filteredData.map(d => d.value))) * 100}%` }}
title={`${point.date}: ${point.value}`}
/>
))}
</div>
</div>
);
}Pasar datos del servidor a componentes cliente
Los datos que pases de Server a Client Components deben ser serializables: strings, números,
booleanos, arrays, objetos planos y null. No puedes pasar funciones, Dates,
Maps, Sets ni instancias de clase.
// CORRECTO: Serializar datos antes de pasarlos
// app/[locale]/users/page.tsx (Server Component)
export default async function UsersPage() {
const users = await db.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
});
// Serializar las fechas a strings ISO antes de pasar a client
const serializedUsers = users.map((user) => ({
...user,
createdAt: user.createdAt.toISOString(),
}));
return <UserTable users={serializedUsers} />;
}
// INCORRECTO: Pasar datos no serializables
// Esto causaría un error en runtime:
// <UserTable
// users={users} // Date objects no son serializables
// onDelete={deleteUser} // Funciones no son serializables
// />// components/UserTable.tsx
"use client";
import { useState } from "react";
interface SerializedUser {
id: string;
name: string;
email: string;
createdAt: string; // ISO string, no Date
}
export function UserTable({ users }: { users: SerializedUser[] }) {
const [sortBy, setSortBy] = useState<"name" | "createdAt">("name");
const sorted = [...users].sort((a, b) =>
sortBy === "name"
? a.name.localeCompare(b.name)
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return (
<table className="w-full border-collapse">
<thead>
<tr className="border-b">
<th
className="p-3 text-left cursor-pointer hover:text-primary-600"
onClick={() => setSortBy("name")}
>
Nombre {sortBy === "name" && "↓"}
</th>
<th className="p-3 text-left">Email</th>
<th
className="p-3 text-left cursor-pointer hover:text-primary-600"
onClick={() => setSortBy("createdAt")}
>
Fecha {sortBy === "createdAt" && "↓"}
</th>
</tr>
</thead>
<tbody>
{sorted.map((user) => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="p-3">{user.name}</td>
<td className="p-3 text-gray-600">{user.email}</td>
<td className="p-3 text-gray-500">
{new Date(user.createdAt).toLocaleDateString("es-ES")}
</td>
</tr>
))}
</tbody>
</table>
);
}Streaming con Suspense y loading.tsx
El streaming permite enviar partes de la página al navegador a medida que están listas, en lugar de esperar a que todo el contenido se genere en el servidor. Esto mejora significativamente la métrica Time to First Byte (TTFB) y la experiencia percibida del usuario.
Archivo loading.tsx
Next.js usa automáticamente un archivo loading.tsx como fallback de Suspense
para la ruta donde se coloca:
// app/[locale]/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="container mx-auto px-4 py-8 animate-pulse">
<div className="h-10 w-48 bg-gray-200 rounded mb-8" />
{/* Skeleton para cards de estadísticas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
))}
</div>
{/* Skeleton para el gráfico */}
<div className="h-80 bg-gray-200 rounded-xl mb-8" />
{/* Skeleton para la lista */}
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded-lg" />
))}
</div>
</div>
);
}Suspense granular
Para un control más fino, envuelve componentes individuales con Suspense
para que cada sección se cargue de forma independiente:
// app/[locale]/dashboard/page.tsx
import { Suspense } from "react";
import { StatsCards, StatsCardsSkeleton } from "@/components/dashboard/StatsCards";
import { RecentActivity, ActivitySkeleton } from "@/components/dashboard/RecentActivity";
import { DashboardChart, ChartSkeleton } from "@/components/dashboard/DashboardChart";
export default function DashboardPage() {
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
{/* Cada sección se carga independientemente */}
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<DashboardChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</main>
);
}
// Cada componente hijo hace su propio fetch
// components/dashboard/StatsCards.tsx (Server Component)
async function StatsCards() {
const stats = await db.analytics.getStats(); // Puede tardar 200ms
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
{stats.map((stat) => (
<div key={stat.label} className="rounded-xl border p-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-3xl font-bold">{stat.value}</p>
</div>
))}
</div>
);
}Error boundaries con error.tsx
Next.js proporciona un mecanismo integrado de manejo de errores a nivel de ruta mediante
el archivo error.tsx. Este actúa como un Error Boundary de React que captura
errores tanto en Server como en Client Components dentro de su segmento de ruta.
// app/[locale]/dashboard/error.tsx
"use client"; // Los error boundaries DEBEN ser Client Components
import { useEffect } from "react";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Registrar el error en un servicio de monitoreo
console.error("Error en Dashboard:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center max-w-md">
<h2 className="text-xl font-semibold text-red-800">
Algo salió mal
</h2>
<p className="mt-2 text-red-600">
No se pudieron cargar los datos del dashboard.
</p>
{error.digest && (
<p className="mt-1 text-sm text-red-400">
Código de error: {error.digest}
</p>
)}
<button
onClick={reset}
className="mt-4 rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700 transition-colors"
>
Intentar de nuevo
</button>
</div>
</div>
);
}
Puntos clave sobre error.tsx:
- Siempre es un Client Component: Debe tener
"use client"porque Error Boundaries son una funcionalidad del lado del cliente en React. - Recibe
reset: Una función que permite al usuario reintentar el renderizado del segmento de ruta. - Propiedad
digest: Un hash del error generado automáticamente por Next.js que se puede usar para rastrear el error en logs del servidor sin exponer detalles sensibles. - Alcance de segmento: Cada carpeta puede tener su propio
error.tsx, permitiendo UI de error granular por sección.
Manejo de not-found
// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound(); // Renderiza el not-found.tsx más cercano
return <article>{/* ... */}</article>;
}
// app/[locale]/blog/not-found.tsx
export default function BlogNotFound() {
return (
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-2xl font-bold">Artículo no encontrado</h2>
<p className="mt-2 text-gray-600">
El artículo que buscas no existe o ha sido eliminado.
</p>
</div>
);
}Importaciones dinámicas con next/dynamic
next/dynamic permite cargar componentes de forma diferida (lazy loading),
dividiéndolos en chunks separados que solo se descargan cuando se necesitan. Es especialmente
útil para componentes pesados o que dependen de APIs del navegador.
// app/[locale]/page.tsx
import dynamic from "next/dynamic";
// El componente solo se carga cuando se renderiza
const HeavyEditor = dynamic(
() => import("@/components/editor/RichTextEditor"),
{
loading: () => (
<div className="h-64 animate-pulse rounded-xl bg-gray-200" />
),
ssr: false, // No renderizar en el servidor (usa APIs del navegador)
}
);
// Componente que solo funciona en el navegador
const MapComponent = dynamic(
() => import("@/components/maps/InteractiveMap"),
{
loading: () => (
<div className="h-96 rounded-xl bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">Cargando mapa...</p>
</div>
),
ssr: false,
}
);
export default function HomePage() {
return (
<main>
<section className="py-12">
<h2 className="text-2xl font-bold mb-4">Editor</h2>
<HeavyEditor />
</section>
<section className="py-12">
<h2 className="text-2xl font-bold mb-4">Ubicación</h2>
<MapComponent />
</section>
</main>
);
}
Cuándo usar dynamic() vs cuándo usar Suspense:
- dynamic() con ssr: false: Para componentes que dependen de APIs del navegador (window, document, canvas). Evita errores de hidratación.
- dynamic() con ssr: true (por defecto): Para code-splitting de componentes pesados. Se renderizan en el servidor pero el JS se carga como chunk separado.
- Suspense: Para streaming de Server Components async. Permite mostrar un fallback mientras el componente se resuelve en el servidor.
Errores comunes y cómo evitarlos
Error 1: Usar hooks en Server Components
// INCORRECTO: useState no funciona en Server Components
// app/[locale]/page.tsx
import { useState } from "react"; // Error en runtime
export default function Page() {
const [count, setCount] = useState(0); // Error: hooks no disponibles
return <p>{count}</p>;
}
// CORRECTO: Extraer la parte interactiva a un Client Component
// app/[locale]/page.tsx (Server Component)
import { Counter } from "@/components/Counter";
export default function Page() {
return (
<main>
<h1>Mi página</h1>
<Counter /> {/* Client Component con useState */}
</main>
);
}Error 2: Importar código del servidor en Client Components
// INCORRECTO: Importar lógica del servidor en un Client Component
"use client";
import { db } from "@/lib/database"; // ERROR: db usa Node.js APIs
export function UserList() {
// Esto nunca funcionará en el cliente
const users = db.user.findMany(); // Error en runtime
return <ul>{/* ... */}</ul>;
}
// CORRECTO: Fetch desde una API route o pasar datos via props
"use client";
import { useState, useEffect } from "react";
export function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users")
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
return <ul>{/* ... */}</ul>;
}Error 3: Poner "use client" demasiado arriba
// INCORRECTO: Marcar todo el layout como Client Component
// app/[locale]/layout.tsx
"use client"; // Ahora NADA en esta rama puede ser Server Component
export default function Layout({ children }) {
return <div>{children}</div>;
}
// CORRECTO: Mantener el layout como Server Component
// Extraer solo las partes interactivas a Client Components
// app/[locale]/layout.tsx (Server Component)
import { Navbar } from "@/components/common/Navbar"; // Client Component
import { Footer } from "@/components/common/Footer"; // Server Component
export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<Navbar /> {/* Solo esto es Client Component */}
{children} {/* Puede contener Server Components */}
<Footer /> {/* Server Component: sin interactividad */}
</div>
);
}Error 4: Pasar datos no serializables a Client Components
// INCORRECTO: Pasar funciones o Dates a Client Components
export default async function Page() {
const data = await getData();
return (
<ClientComponent
date={new Date()} // Error: Date no es serializable
onClick={() => {}} // Error: funciones no son serializables
map={new Map()} // Error: Map no es serializable
/>
);
}
// CORRECTO: Serializar todo antes de pasarlo
export default async function Page() {
const data = await getData();
return (
<ClientComponent
date={new Date().toISOString()} // String ISO
items={Array.from(someMap)} // Array de tuplas
/>
);
}Consejo final: Los React Server Components no son un reemplazo de los Client Components, sino un complemento. La clave está en encontrar el equilibrio correcto: usa Server Components para fetching, transformación de datos y renderizado estático; usa Client Components exclusivamente para interactividad. Mantén la directiva
"use client"lo más abajo posible en el árbol de componentes para maximizar los beneficios de RSC. Esta arquitectura produce aplicaciones más rápidas, más ligeras y más fáciles de mantener.