Tema
Server vs Client Components
Es el concepto que más confunde al empezar con Next.js y, a la vez, el que más poder te da. En el App Router hay dos tipos de componentes: los que se ejecutan sólo en el servidor y los que también se ejecutan en el navegador. Aprenderás qué puede hacer cada uno, cómo combinarlos y los errores típicos.
Concepto
| Server Component (por defecto) | Client Component ('use client') | |
|---|---|---|
| Dónde se ejecuta | Sólo en el servidor | Servidor (HTML inicial) y navegador |
| JavaScript enviado al navegador | Ninguno | Sí |
async/await en el componente | ✅ | ❌ |
| Acceso directo a base de datos, archivos, secretos | ✅ | ❌ |
useState, useEffect, useContext | ❌ | ✅ |
Eventos (onClick, onChange) | ❌ | ✅ |
APIs del navegador (window, localStorage) | ❌ | ✅ |
Analogía: un Server Component es como la cocina de un restaurante: prepara todo con ingredientes que el cliente nunca ve (base de datos, claves secretas) y entrega el platillo terminado. Un Client Component es el mesero: está en el salón, interactúa contigo y responde a lo que pides.
Por defecto: servidor
Todos los componentes en app/ son Server Components a menos que el archivo empiece con 'use client'.
tsx
// app/productos/page.tsx — Server Component
// Puede ser async y obtener datos directamente
type Producto = { id: number; title: string; price: number }
export default async function Productos() {
const res = await fetch('https://dummyjson.com/products?limit=5&select=title,price')
const { products }: { products: Producto[] } = await res.json()
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.title} — ${p.price}</li>
))}
</ul>
)
}Este componente no envía JavaScript al navegador: sólo HTML. Además, podría leer de la base de datos directamente sin crear una API (lo verás con Prisma).
Client Components: interactividad
tsx
// components/BotonMeGusta.tsx
'use client' // ← primera línea del archivo
import { useState } from 'react'
export default function BotonMeGusta({ inicial = 0 }: { inicial?: number }) {
const [likes, setLikes] = useState(inicial)
const [activo, setActivo] = useState(false)
return (
<button
onClick={() => {
setActivo(!activo)
setLikes((l) => (activo ? l - 1 : l + 1))
}}
aria-pressed={activo}
className={`rounded-full border px-4 py-1 ${activo ? 'border-pink-500 text-pink-600' : ''}`}
>
{activo ? '❤️' : '🤍'} {likes}
</button>
)
}Los Client Components también se renderizan en el servidor
'use client' no significa "sólo en el navegador". Next.js genera su HTML inicial en el servidor (para que se vea rápido) y luego lo hidrata en el navegador para hacerlo interactivo. Por eso no puedes usar window en el cuerpo del componente: úsalo dentro de useEffect o de manejadores de eventos.
Combinar ambos
La regla: 'use client' marca una frontera. Ese archivo y todo lo que importa se convierte en código de cliente.
txt
page.tsx (Server)
├── Header (Server)
├── ListaProductos (Server) ← obtiene datos
│ └── TarjetaProducto (Server)
│ └── BotonAgregar (Client) ← sólo esta parte es interactiva
└── Footer (Server)Patrón 1: Server Component con hijos Client
El caso más común. El servidor obtiene los datos y se los pasa como props al componente interactivo:
tsx
import BotonAgregar from '@/components/BotonAgregar'
type Producto = { id: number; title: string; price: number; description: string; stock: number }
export default async function PaginaProducto({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const res = await fetch(`https://dummyjson.com/products/${id}`)
const p: Producto = await res.json()
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-3xl font-bold">{p.title}</h1>
<p className="my-4">{p.description}</p>
<p className="text-2xl font-bold">${p.price}</p>
{/* Pasamos sólo los datos necesarios (serializables) */}
<BotonAgregar productoId={p.id} stock={p.stock} />
</main>
)
}tsx
'use client'
import { useState } from 'react'
export default function BotonAgregar({ productoId, stock }: { productoId: number; stock: number }) {
const [cantidad, setCantidad] = useState(1)
const [agregado, setAgregado] = useState(false)
function agregar() {
// Aquí iría la lógica del carrito (Zustand, localStorage, Server Action...)
console.log('Agregar', productoId, cantidad)
setAgregado(true)
setTimeout(() => setAgregado(false), 2000)
}
return (
<div className="mt-4 flex items-center gap-3">
<input
type="number"
min={1}
max={stock}
value={cantidad}
onChange={(e) => setCantidad(Number(e.target.value))}
className="w-20 rounded border px-2 py-1"
aria-label="Cantidad"
/>
<button onClick={agregar} className="rounded bg-violet-600 px-4 py-2 text-white">
{agregado ? '✓ Agregado' : 'Agregar al carrito'}
</button>
</div>
)
}Las props deben ser serializables
Lo que pasas de un Server Component a un Client Component viaja por la red, así que debe poder convertirse a JSON (más algunos tipos extra como Date, Map o promesas):
tsx
// ✅ strings, números, booleanos, arreglos, objetos planos, Date
<BotonAgregar productoId={1} stock={10} />
// ❌ funciones normales (no se pueden serializar)
<BotonAgregar onAgregar={() => console.log('hola')} />
// ✅ excepción: Server Actions sí se pueden pasar (lección 7)Patrón 2: Server Components como children de un Client Component
Un Client Component no puede importar un Server Component, pero sí puede recibirlo como children (o cualquier prop con JSX). El Server Component se renderiza en el servidor y se pasa ya listo:
tsx
'use client'
import { useState } from 'react'
export default function Acordeon({ titulo, children }: { titulo: string; children: React.ReactNode }) {
const [abierto, setAbierto] = useState(false)
return (
<section className="rounded border">
<button onClick={() => setAbierto(!abierto)} aria-expanded={abierto} className="w-full p-3 text-left font-semibold">
{titulo} {abierto ? '−' : '+'}
</button>
{abierto && <div className="border-t p-3">{children}</div>}
</section>
)
}tsx
import Acordeon from '@/components/Acordeon'
// Server Component: obtiene datos en el servidor
async function Politicas() {
const res = await fetch('https://dummyjson.com/products/1?select=returnPolicy,shippingInformation')
const datos = await res.json()
return (
<ul className="list-disc pl-5">
<li>{datos.returnPolicy}</li>
<li>{datos.shippingInformation}</li>
</ul>
)
}
export default function FAQ() {
return (
<main className="mx-auto max-w-2xl space-y-3 p-8">
<h1 className="text-3xl font-bold">Preguntas frecuentes</h1>
<Acordeon titulo="¿Cuáles son sus políticas?">
{/* Un Server Component dentro de un Client Component, vía children */}
<Politicas />
</Acordeon>
</main>
)
}Este patrón es clave para proveedores de contexto (tema, carrito, sesión): el Provider es Client, pero tus páginas siguen siendo Server Components:
tsx
// components/Providers.tsx
'use client'
import { ThemeProvider } from './ThemeContext'
export default function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>
}
// app/layout.tsx (Server Component)
// <body><Providers>{children}</Providers></body>Mantener el código de servidor en el servidor
Si un módulo tiene secretos o acceso a base de datos, protégelo para que nunca se importe por error en un Client Component:
bash
npm i server-onlyts
// lib/datos.ts
import 'server-only' // si un Client Component lo importa, el build falla
export async function obtenerVentas() {
const res = await fetch('https://api.interna.com/ventas', {
headers: { Authorization: `Bearer ${process.env.API_SECRET}` }
})
return res.json()
}¿Cuándo usar cada uno?
| Necesitas... | Usa |
|---|---|
| Obtener datos, acceder a la base de datos o a secretos | Server |
| Mostrar contenido estático (textos, listas, tarjetas) | Server |
| Reducir el JavaScript enviado | Server |
useState, useEffect, useReducer, contexto | Client |
| Eventos: clics, inputs, formularios interactivos | Client |
APIs del navegador (localStorage, geolocalización) | Client |
Librerías que usan hooks o window (mapas, gráficas, carruseles) | Client |
Regla práctica: empuja 'use client' lo más abajo posible en el árbol. En lugar de convertir toda la página en Client por un botón, extrae el botón a su propio componente cliente.
Librerías que usan window
Algunas librerías (mapas, editores) fallan en el servidor porque usan window al importarse. Cárgalas sólo en el navegador con dynamic:
tsx
// components/MapaWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
const Mapa = dynamic(() => import('./Mapa'), {
ssr: false, // no se renderiza en el servidor
loading: () => <div className="h-80 animate-pulse rounded bg-gray-200" />
})
export default function MapaWrapper() {
return <Mapa />
}Buena práctica
Piensa en los Client Components como "islas de interactividad" dentro de páginas renderizadas en el servidor. Cuanto más pequeñas las islas, más rápido tu sitio.
Errores comunes
useState only works in Client Components: usaste un hook en un Server Component. Agrega'use client'a ese archivo (o extrae la parte interactiva).window is not defined: usastewindowolocalStoragedurante el render. Muévelo auseEffecto usadynamicconssr: false.- "Functions cannot be passed directly to Client Components": pasaste una función como prop desde un Server Component. Mueve la lógica al cliente o usa una Server Action.
Ejercicios
- Crea una página
/clima(Server Component) que muestre una lista de 5 ciudades, y un Client ComponentFavoritocon una estrella que se pueda marcar en cada ciudad. - Crea un
TemaProvider(Client) con un botón para alternar claro/oscuro, envuelve tu layout raíz y verifica que las páginas sigan siendo Server Components (pueden serasync).
Ver solución
tsx
// components/Favorito.tsx
'use client'
import { useState } from 'react'
export default function Favorito({ nombre }: { nombre: string }) {
const [fav, setFav] = useState(false)
return (
<button onClick={() => setFav(!fav)} aria-label={`Marcar ${nombre} como favorita`} aria-pressed={fav}>
{fav ? '★' : '☆'}
</button>
)
}
// app/clima/page.tsx
import Favorito from '@/components/Favorito'
const ciudades = ['CDMX', 'Guadalajara', 'Monterrey', 'Mérida', 'Tijuana']
export default function Clima() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold">Ciudades</h1>
<ul>
{ciudades.map((c) => (
<li key={c} className="flex gap-2">
{c} <Favorito nombre={c} />
</li>
))}
</ul>
</main>
)
}tsx
// components/TemaProvider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
const TemaCtx = createContext<{ tema: string; alternar: () => void } | null>(null)
export function TemaProvider({ children }: { children: React.ReactNode }) {
const [tema, setTema] = useState('claro')
useEffect(() => {
document.documentElement.classList.toggle('dark', tema === 'oscuro')
}, [tema])
const alternar = () => setTema((t) => (t === 'claro' ? 'oscuro' : 'claro'))
return <TemaCtx value={{ tema, alternar }}>{children}</TemaCtx>
}
export function BotonTema() {
const ctx = useContext(TemaCtx)
if (!ctx) return null
return <button onClick={ctx.alternar}>{ctx.tema === 'claro' ? '🌙' : '☀️'}</button>
}
// app/layout.tsx
// <body><TemaProvider><header><BotonTema /></header>{children}</TemaProvider></body>Las páginas dentro de children siguen siendo Server Components porque se pasan como children al Provider.
Resumen
- Por defecto los componentes son de servidor: pueden ser
async, acceder a datos y secretos, y no envían JavaScript. 'use client'crea un Client Component para estado, efectos, eventos y APIs del navegador.- Pasa datos serializables de servidor a cliente por props; pasa Server Components a Client Components vía
children. - Empuja
'use client'lo más abajo posible y protege módulos sensibles conserver-only.