Referencia Detallada de React Server Components: Arquitectura RSC
Dominá React Server Components: arquitectura RSC, data loading, streaming, server actions y client component boundaries en Next.js App Router.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Introducción
React Server Components (RSC) ejecutan en el server y mandan HTML al browser en vez de JavaScript. Pueden acceder a databases, file systems e internal APIs directamente sin exponer secrets al client. RSC reduce el JavaScript bundle size y mejora el initial page load. En Next.js App Router, los components son server components por default. A continuación: RSC architecture, data loading patterns, streaming con Suspense, server actions y el boundary entre server y client components.
Cómo Funciona RSC
Traditional CSR (Client-Side Rendering):
Browser → descarga JS bundle → ejecuta React → fetchea data → rendera
RSC (Server Components):
Server → fetchea data → rendera React a HTML → streamea al browser
Browser → recibe HTML → hydrata solo partes interactivas (Client Components)
Key differences:
- Server Components: No useState, no event handlers, no browser APIs
- Client Components: "use client" directive, full React features
- Server Components pueden importar Client Components, no viceversa
- Server Components pueden acceder a databases, filesystem, env vars directamente
Server vs Client Components
Server Component (default)
// app/products/page.tsx — Server Component (default, no directive)
import { db } from "@/lib/db";
import { ProductCard } from "./ProductCard";
// Esto ejecuta en el server. No "use client" needed.
export default async function ProductsPage() {
// Direct database access — no API route needed
const products = await db.product.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 20,
});
return (
<div className="grid grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Client Component
// app/products/ProductCard.tsx — Client Component
"use client";
import { useState } from "react";
import { addToCart } from "./actions";
interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
image: string;
};
}
export function ProductCard({ product }: ProductCardProps) {
const [isAdding, setIsAdding] = useState(false);
const [quantity, setQuantity] = useState(1);
async function handleAddToCart() {
setIsAdding(true);
try {
await addToCart(product.id, quantity);
} finally {
setIsAdding(false);
}
}
return (
<div className="border rounded-lg p-4">
<img src={product.image} alt={product.name} className="w-full h-48 object-cover" />
<h3 className="mt-2 font-semibold">{product.name}</h3>
<p className="text-gray-600">${product.price}</p>
<div className="mt-4 flex items-center gap-2">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="px-3 py-1 border rounded"
>
-
</button>
<span>{quantity}</span>
<button
onClick={() => setQuantity((q) => q + 1)}
className="px-3 py-1 border rounded"
>
+
</button>
<button
onClick={handleAddToCart}
disabled={isAdding}
className="ml-auto px-4 py-2 bg-blue-600 text-white rounded"
>
{isAdding ? "Adding..." : "Add to Cart"}
</button>
</div>
</div>
);
}
Data Loading Patterns
Parallel data fetching
// app/dashboard/page.tsx — Parallel data loading
import { Suspense } from "react";
import { db } from "@/lib/db";
async function UserStats() {
const stats = await db.user.aggregate({ _count: true, where: { active: true } });
return <div>Active users: {stats._count}</div>;
}
async function RevenueChart() {
const revenue = await db.order.aggregate({
_sum: { total: true },
where: { status: "completed" },
});
return <div>Total revenue: ${revenue._sum.total}</div>;
}
async function RecentOrders() {
const orders = await db.order.findMany({ take: 10, orderBy: { createdAt: "desc" } });
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.id} — ${order.total}</li>
))}
</ul>
);
}
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1>Dashboard</h1>
{/* Los tres fetchean in parallel */}
<Suspense fallback={<div>Loading stats...</div>}>
<UserStats />
</Suspense>
<Suspense fallback={<div>Loading revenue...</div>}>
<RevenueChart />
</Suspense>
<Suspense fallback={<div>Loading orders...</div>}>
<RecentOrders />
</Suspense>
</div>
);
}
Sequential data fetching con caching
// app/blog/[slug]/page.tsx — Sequential con caching
import { notFound } from "next/navigation";
import { cache } from "react";
// Cacheá el fetch dentro de un single request
const getPost = cache(async (slug: string) => {
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
return post;
});
export default async function BlogPost({ params }: { params: { slug: string } }) {
// First fetch
const post = await getPost(params.slug);
// Second fetch depende del first
const author = await db.user.findUnique({ where: { id: post.authorId } });
const comments = await db.comment.findMany({
where: { postId: post.id },
include: { author: true },
orderBy: { createdAt: "desc" },
});
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div>{post.content}</div>
<section>
<h2>Comments ({comments.length})</h2>
{comments.map((comment) => (
<div key={comment.id}>
<strong>{comment.author.name}</strong>
<p>{comment.text}</p>
</div>
))}
</section>
</article>
);
}
Data fetching con revalidation
// app/products/page.tsx — ISR con revalidate
export const revalidate = 3600; // Revalidá cada hour
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // Cacheá por 1 hour
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
// Dynamic rendering — no cache
export const dynamic = "force-dynamic";
async function getRealtimeData() {
const res = await fetch("https://api.example.com/live", {
cache: "no-store",
});
return res.json();
}
Streaming con Suspense
// app/dashboard/page.tsx — Streaming con Suspense boundaries
import { Suspense } from "react";
// Slow component que takes 2s en cargar
async function SlowChart() {
await new Promise((resolve) => setTimeout(resolve, 2000));
const data = await fetch("https://api.example.com/chart-data").then((r) => r.json());
return <Chart data={data} />;
}
// Fast component que carga immediately
async function QuickStats() {
const data = await db.stats.findFirst();
return <div>Users: {data.users} | Orders: {data.orders}</div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast content rendera immediately */}
<Suspense fallback={<div>Loading stats...</div>}>
<QuickStats />
</Suspense>
{/* Slow content streamea después de 2s */}
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<SlowChart />
</Suspense>
</div>
);
}
Server Actions
// app/products/actions.ts — Server Actions
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
const CreateProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
description: z.string().min(10).max(500),
image: z.string().url(),
});
export async function createProduct(formData: FormData) {
const session = await auth();
if (!session?.user?.isAdmin) {
throw new Error("Unauthorized");
}
const parsed = CreateProductSchema.parse({
name: formData.get("name"),
price: Number(formData.get("price")),
description: formData.get("description"),
image: formData.get("image"),
});
await db.product.create({ data: parsed });
// Revalidá la products page
revalidatePath("/products");
}
export async function deleteProduct(productId: string) {
const session = await auth();
if (!session?.user?.isAdmin) {
throw new Error("Unauthorized");
}
await db.product.delete({ where: { id: productId } });
revalidatePath("/products");
}
// Optimistic update con useActionState
export async function toggleLike(productId: string) {
const session = await auth();
if (!session) throw new Error("Login required");
const existing = await db.like.findFirst({
where: { productId, userId: session.user.id },
});
if (existing) {
await db.like.delete({ where: { id: existing.id } });
} else {
await db.like.create({ data: { productId, userId: session.user.id } });
}
revalidatePath(`/products/${productId}`);
}
// app/products/CreateProductForm.tsx — Usando Server Actions en Client Component
"use client";
import { useActionState } from "react";
import { createProduct } from "./actions";
export function CreateProductForm() {
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
try {
await createProduct(formData);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
},
null
);
return (
<form action={formAction} className="space-y-4">
<input name="name" type="text" placeholder="Product name" required />
<input name="price" type="number" placeholder="Price" required />
<textarea name="description" placeholder="Description" required />
<input name="image" type="url" placeholder="Image URL" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Product"}
</button>
{state?.success && <p className="text-green-600">Product created!</p>}
{state?.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
Component Boundaries
Rules para "use client":
1. Server Components pueden importar Client Components ✓
2. Client Components no pueden importar Server Components ✗
3. Client Components pueden recibir Server Components como children ✓
4. Server Components pueden pasar serializable props a Client Components ✓
5. Server Components no pueden pasar functions a Client Components ✗
(excepto server actions)
Pattern: Server Component wrapper con Client Component children
// app/layout.tsx (Server Component)
<ClientProvider> // Client Component
<PageContent /> // Server Component passed como children
</ClientProvider>
// app/layout.tsx — Server Component pasando children a Client Component
import { ThemeProvider } from "./ThemeProvider"; // Client Component
import { Navbar } from "./Navbar"; // Server Component
import { db } from "@/lib/db";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const categories = await db.category.findMany(); // Server-side DB query
return (
<html>
<body>
{/* Client Component wrapping Server Component children */}
<ThemeProvider>
<Navbar categories={categories} />
{children}
</ThemeProvider>
</body>
</html>
);
}
Error Handling
// app/products/[id]/error.tsx — Error boundary para RSC
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-4 border border-red-300 rounded bg-red-50">
<h2 className="text-red-800 font-semibold">Something went wrong</h2>
<p className="text-red-600 text-sm">{error.message}</p>
<button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
Try again
</button>
</div>
);
}
// app/products/[id]/loading.tsx — Loading UI
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/2" />
<div className="h-64 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
</div>
);
}
// app/products/[id]/not-found.tsx — 404 page
export default function NotFound() {
return (
<div className="text-center py-12">
<h1 className="text-2xl font-bold">Product not found</h1>
<p className="text-gray-600 mt-2">The product you're looking for doesn't exist.</p>
</div>
);
}
Best Practices
-
For a deeper guide, see Complete Guide to React 19 Features.
-
Default a Server Components — solo agregá “use client” cuando necesitás interactivity
-
Mantené client components chicas — pushéalas a las leaves del component tree
-
Usá Suspense para streaming — no blockeés toda la page en slow data
-
Validá input en Server Actions — usá Zod o similar para type-safe validation
-
Usá
revalidatePathdespués de mutations — mantené cached pages fresh -
Pasá serializable props only — strings, numbers, arrays, plain objects
-
Usá
cache()para deduplication — evitá duplicate queries dentro de un single request -
Co-localizá data fetching con el component — RSC elimina la need de separate API routes
-
Usá error boundaries per route segment — isolá failures a specific sections
-
Seteá
export const revalidatepara ISR — controlá cómo often pages revalidate
Common Mistakes
- “use client” al top del file: hace todo un client component. Solo markéa components que necesitan interactivity.
- Pasar non-serializable props: functions, class instances, o Dates no pueden cross el server-client boundary. Usá server actions para functions.
- Importar Server Components en Client Components: esto break. Pasalos como
childrenprops en vez. - No usar Suspense: toda la page espera por la slowest query. Wrapéa slow components en Suspense.
- Olvidar revalidate: después de un server action mutation, la cached page muestra stale data. Llamá
revalidatePath.
FAQ
¿Qué son React Server Components?
Components que renderan en el server y mandan HTML al browser. Pueden acceder a databases, file systems e internal APIs directamente. No shipéan JavaScript al client, reduciendo bundle size.
¿Cuándo debería usar “use client”?
Cuando un component necesita: useState, useEffect, event handlers (onClick, onChange), browser APIs (window, localStorage), o third-party libraries que dependen de client-side React features.
¿Se pueden mezclar Server Components y Client Components?
Sí. Server Components pueden importar y renderar Client Components. Client Components pueden recibir Server Components como children props. Pero Client Components no pueden directamente importar Server Components.
¿Qué son Server Actions?
Functions que ejecutan en el server pero pueden ser called desde Client Components. Reemplazan traditional API routes para form submissions y mutations. Markéalos con "use server" directive.
¿Cómo funciona streaming con RSC?
Server Components wrapped en <Suspense> streamean su HTML a medida que están ready. Fast components renderan immediately, y slow components streamean cuando su data está available. El browser muestra fallback UI hasta que el streamed content arrive.
Recursos Relacionados
Complete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.
GuideReact State Management: Context, Zustand, TanStack Query
Master React state management: Context API, Zustand, Jotai, and TanStack Query for server state. Covers patterns, persistence, optimistic updates, and when to use each.
GuideComplete Guide to TypeScript Advanced Types
Master TypeScript advanced types: conditional types, mapped types, template literal types, infer, distributive types, and type-level programming patterns.