React Server Components (RSC) son componentes que se ejecutan exclusivamente en el servidor. No envian JavaScript al navegador, no tienen acceso a APIs del browser y no pueden usar hooks como useState o useEffect. A cambio, pueden acceder directamente a bases de datos, leer el filesystem y hacer fetch de datos sin exponer secrets ni aumentar el bundle del cliente.
Desde Next.js 13 con App Router, todos los componentes son Server Components por defecto. Esto es un cambio fundamental respecto a Pages Router, donde todo era cliente por defecto. Si creas un archivo app/page.tsx sin ninguna directiva especial, ese componente se renderiza en el servidor, genera HTML y lo envia al navegador sin JavaScript adicional.
// app/productos/page.tsx
// Esto es un Server Component por defecto
async function ProductosPage() {
const productos = await fetch('https://api.ejemplo.com/productos', {
cache: 'force-cache'
}).then(res => res.json())
return (
<main>
<h1>Productos</h1>
<ul>
{productos.map(p => (
<li key={p.id}>{p.nombre} - ${p.precio}</li>
))}
</ul>
</main>
)
}
export default ProductosPage
Observa que la funcion es async. Los Server Components pueden ser asincronos directamente — algo imposible en Client Components. El fetch se ejecuta en el servidor durante el renderizado, y el navegador recibe HTML puro con la lista de productos ya renderizada.
La directiva "use client" al inicio del archivo marca ese componente (y todo lo que importa) como Client Component. Debes usarla cuando necesitas:
useState, useEffect, useReducer, useRef, useContextonClick, onChange, onSubmit y cualquier interaccion del usuariowindow, document, localStorage, navigator'use client'
import { useState } from 'react'
function Contador() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Clicks: {count}
</button>
)
}
export default Contador
Sin "use client", este componente lanzaria un error porque useState no existe en el contexto de servidor.
La clave para entender RSC es pensar en terminos de responsabilidades:
| Aspecto | Server Component | Client Component | |---|---|---| | Fetch de datos | Directo, con async/await | Via useEffect o SWR/React Query | | Estado interactivo | No disponible | useState, useReducer | | Efectos secundarios | No disponible | useEffect | | Acceso a secrets | Si (env vars, DB) | No (expone al cliente) | | JavaScript al bundle | 0 bytes | Todo el codigo del componente | | Renderizado | En el servidor, una vez | En el navegador, reactivo |
El modelo mental mas util es: el servidor se encarga de obtener y renderizar datos, el cliente se encarga de la interactividad. No es que uno sea mejor que otro — tienen roles complementarios.
El patron mas comun es que un Server Component obtiene datos y los pasa como props a un Client Component que maneja la interaccion:
// app/tienda/page.tsx (Server Component)
import { FiltroProductos } from './FiltroProductos'
async function TiendaPage() {
const productos = await obtenerProductos()
const categorias = await obtenerCategorias()
return (
<main>
<h1>Tienda</h1>
<FiltroProductos
productos={productos}
categorias={categorias}
/>
</main>
)
}
// app/tienda/FiltroProductos.tsx (Client Component)
'use client'
import { useState } from 'react'
interface Producto {
id: string
nombre: string
categoria: string
precio: number
}
interface Props {
productos: Producto[]
categorias: string[]
}
export function FiltroProductos({ productos, categorias }: Props) {
const [categoriaActiva, setCategoriaActiva] = useState<string>('todas')
const [busqueda, setBusqueda] = useState('')
const filtrados = productos.filter(p => {
const matchCategoria = categoriaActiva === 'todas'
|| p.categoria === categoriaActiva
const matchBusqueda = p.nombre
.toLowerCase()
.includes(busqueda.toLowerCase())
return matchCategoria && matchBusqueda
})
return (
<div>
<input
type="text"
placeholder="Buscar productos..."
value={busqueda}
onChange={e => setBusqueda(e.target.value)}
/>
<div className="flex gap-2 my-4">
<button
onClick={() => setCategoriaActiva('todas')}
className={categoriaActiva === 'todas' ? 'font-bold' : ''}
>
Todas
</button>
{categorias.map(cat => (
<button
key={cat}
onClick={() => setCategoriaActiva(cat)}
className={categoriaActiva === cat ? 'font-bold' : ''}
>
{cat}
</button>
))}
</div>
<ul>
{filtrados.map(p => (
<li key={p.id}>{p.nombre} - ${p.precio}</li>
))}
</ul>
</div>
)
}
Este patron tiene dos ventajas claras: los datos se obtienen en el servidor (rapido, seguro, sin waterfall en el cliente) y la interactividad se maneja en el navegador donde corresponde.
Un Client Component puede recibir Server Components como children. Esto permite envolver contenido renderizado en servidor con interactividad de cliente:
// app/layout.tsx (Server Component)
import { Sidebar } from './Sidebar'
import { ContenidoPrincipal } from './ContenidoPrincipal'
export default function Layout({ children }) {
return (
<div className="flex">
<Sidebar>
{/* Esto se renderiza en el servidor */}
<NavegacionPrincipal />
</Sidebar>
<main>{children}</main>
</div>
)
}
// app/Sidebar.tsx (Client Component)
'use client'
import { useState } from 'react'
export function Sidebar({ children }: { children: React.ReactNode }) {
const [abierto, setAbierto] = useState(true)
return (
<aside className={abierto ? 'w-64' : 'w-16'}>
<button onClick={() => setAbierto(!abierto)}>
{abierto ? 'Cerrar' : 'Abrir'}
</button>
{abierto && children}
</aside>
)
}
El componente NavegacionPrincipal sigue siendo un Server Component aunque este dentro de un Client Component, porque se pasa como children (un ReactNode ya renderizado), no como una importacion directa.
// Directo, sin hooks, sin loading states manuales
async function UsuarioPage({ params }: { params: { id: string } }) {
const usuario = await db.usuarios.findUnique({
where: { id: params.id }
})
if (!usuario) return notFound()
return <PerfilUsuario usuario={usuario} />
}
'use client'
import { useState, useEffect } from 'react'
function BusquedaEnVivo() {
const [query, setQuery] = useState('')
const [resultados, setResultados] = useState([])
useEffect(() => {
if (query.length < 3) return
const controller = new AbortController()
fetch(`/api/buscar?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResultados(data))
.catch(() => {})
return () => controller.abort()
}, [query])
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Buscar..."
/>
<ul>
{resultados.map(r => <li key={r.id}>{r.titulo}</li>)}
</ul>
</div>
)
}
El fetch en Client Components es necesario cuando la query depende de una interaccion del usuario (como una busqueda en vivo) que no se puede predecir en el servidor.
Cada componente marcado con "use client" envia su JavaScript al navegador. Esto incluye el codigo del componente mas todas sus dependencias. Si un Client Component importa una libreria de 50KB, esos 50KB van al bundle del cliente.
Por eso es importante mantener los Client Components lo mas pequenos posible y empujar la mayor cantidad de logica hacia el servidor. Un error comun es marcar una pagina entera como "use client" porque un pequeno boton necesita onClick. En lugar de eso, extrae solo el boton a un Client Component separado.
// MAL: toda la pagina es Client Component por un boton
'use client'
export default function ArticuloPage() {
// 90% de este componente es contenido estatico
return (
<article>
<h1>Titulo largo del articulo</h1>
<p>Parrafo 1...</p>
<p>Parrafo 2...</p>
{/* ... mucho contenido estatico ... */}
<button onClick={() => navigator.share({...})}>
Compartir
</button>
</article>
)
}
// BIEN: solo el boton es Client Component
// app/articulo/page.tsx (Server Component)
import { BotonCompartir } from './BotonCompartir'
export default function ArticuloPage() {
return (
<article>
<h1>Titulo largo del articulo</h1>
<p>Parrafo 1...</p>
<p>Parrafo 2...</p>
<BotonCompartir titulo="Titulo largo del articulo" />
</article>
)
}
// app/articulo/BotonCompartir.tsx
'use client'
export function BotonCompartir({ titulo }: { titulo: string }) {
return (
<button onClick={() => navigator.share({ title: titulo })}>
Compartir
</button>
)
}
// ERROR: useState no existe en el servidor
async function Page() {
const [count, setCount] = useState(0) // Error en build
return <div>{count}</div>
}
El error de Next.js sera claro: useState only works in Client Components. La solucion es agregar "use client" o mover el estado a un componente hijo.
'use client'
import { db } from '@/lib/database' // PELIGRO
function Panel() {
// Esto intentaria incluir el driver de la DB en el bundle del cliente
}
Para prevenir esto, usa el paquete server-only:
// lib/database.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
export const db = new PrismaClient()
Si un Client Component intenta importar este modulo, el build fallara con un error explicito en lugar de silenciosamente incluir codigo del servidor en el cliente.
// Esto NO funciona
async function Page() {
const handleClick = () => console.log('click')
return <ClientButton onClick={handleClick} /> // Error de serializacion
}
Las props que cruzan la frontera servidor-cliente deben ser serializables: strings, numeros, booleanos, arrays y objetos planos. Las funciones, clases, Dates y otros tipos complejos no se pueden pasar directamente. Para acciones que necesitan ejecutar logica en el servidor, usa Server Actions con la directiva "use server".
La regla general es simple: empieza siempre con Server Components. Solo agrega "use client" cuando necesitas interactividad. Mantiene los Client Components pequenos y focalizados. Usa el patron de composicion para combinar datos del servidor con interacciones del cliente. Este enfoque te dara el mejor rendimiento posible porque minimiza el JavaScript que se envia al navegador mientras mantienes toda la interactividad que el usuario necesita.