Composición de Server y Client Components
Combinar Server Components (componentes del servidor) y Client Components (componentes del cliente) correctamente es fundamental en NextJS 15. Esta guía te enseña las reglas, patrones comunes, y cómo estructurar tu aplicación para aprovechar lo mejor de ambos mundos.
Las reglas básicas
Antes de ver patrones, entiende estas reglas fundamentales:
Regla 1: Server Components pueden renderizar Client Components
// app/productos/page.tsx (Server Component)
import BotonComprar from '@/components/BotonComprar' // Client Component
export default async function ProductosPage() {
const productos = await obtenerProductos() // Server
return (
<div>
{productos.map(producto => (
<div key={producto.id}>
<h2>{producto.nombre}</h2>
<BotonComprar producto={producto} /> {/* Client dentro de Server */}
</div>
))}
</div>
)
}
Esto funciona perfectamente.
Regla 2: Server Components pueden renderizar Server Components
// app/dashboard/page.tsx (Server Component)
import Header from '@/components/Header' // Server Component
import Footer from '@/components/Footer' // Server Component
export default async function DashboardPage() {
return (
<div>
<Header /> {/* Server dentro de Server */}
<main>Contenido</main>
<Footer /> {/* Server dentro de Server */}
</div>
)
}
Sin problemas.
Regla 3: Client Components pueden renderizar Client Components
// components/Modal.tsx
'use client'
import ModalHeader from './ModalHeader' // Client Component
import ModalContent from './ModalContent' // Client Component
export default function Modal() {
return (
<div>
<ModalHeader /> {/* Client dentro de Client */}
<ModalContent /> {/* Client dentro de Client */}
</div>
)
}
También funciona.
Regla 4: Client Components NO pueden importar Server Components
// components/ClientWrapper.tsx
'use client'
import ServerComponent from './ServerComponent' // Server Component
export default function ClientWrapper() {
// ❌ ERROR: No puedes importar Server Components en Client Components
return <ServerComponent />
}
Esto NO funciona y causará un error.
La regla de oro
Una vez que cruzas el límite a Client Component (usando 'use client'), todo lo que importes directamente también se vuelve Client Component. No puedes "volver" a Server Component mediante imports.
¿Por qué Client no puede importar Server?
Piénsalo así: Client Components se ejecutan en el navegador del usuario. El código del navegador no puede acceder a:
- Tu base de datos
- Archivos del servidor
- Variables de entorno secretas
- APIs internas
Si pudieras importar Server Components en Client, estarías enviando código del servidor al navegador, exponiendo secretos y acceso a tu base de datos.
// ❌ Esto sería un desastre de seguridad
'use client'
import { db } from '@/lib/database' // Expondrías tu DB al navegador
export default function ClientComponent() {
// El navegador tendría acceso a tu base de datos
const datos = db.usuarios.findMany() // Peligro
return <div>{datos}</div>
}
El patrón children: La solución
Aunque Client Components no pueden importar Server Components, pueden recibirlos como props (propiedades), especialmente children
(contenido hijo):
// app/page.tsx (Server Component)
import ClientWrapper from '@/components/ClientWrapper'
import ServerData from '@/components/ServerData'
export default function Page() {
return (
<ClientWrapper>
<ServerData /> {/* Server Component pasado como children */}
</ClientWrapper>
)
}
// components/ClientWrapper.tsx
'use client'
export default function ClientWrapper({
children
}: {
children: React.ReactNode
}) {
return (
<div className="wrapper">
{children} {/* Renderiza el Server Component */}
</div>
)
}
// components/ServerData.tsx (Server Component)
export default async function ServerData() {
const datos = await obtenerDatos() // Acceso al servidor
return <div>{datos}</div>
}
Qué sucede:
Page
(Server) renderizaClientWrapper
Page
pasaServerData
como childrenServerData
se ejecuta en el servidorClientWrapper
recibe el HTML ya renderizadoClientWrapper
simplemente lo muestra
Children es una prop especial
children
(contenido hijo) no es la única prop (propiedad) que puedes usar. Cualquier prop que acepte ReactNode
funciona: header
, sidebar
, footer
, etc.
Patrones comunes de composición
Patrón 1: Layout interactivo con contenido del servidor
Caso de uso: Sidebar colapsable con contenido dinámico del servidor.
// app/dashboard/layout.tsx (Server Component)
import Sidebar from '@/components/Sidebar'
import { obtenerUsuario } from '@/lib/auth'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const usuario = await obtenerUsuario() // Server
return (
<div className="flex">
<Sidebar usuario={usuario}>
{/* Navegación del servidor */}
<NavegacionServidor />
</Sidebar>
<main className="flex-1">
{children} {/* Páginas del servidor */}
</main>
</div>
)
}
// components/Sidebar.tsx
'use client'
import { useState } from 'react'
export default function Sidebar({
usuario,
children
}: {
usuario: { nombre: string }
children: React.ReactNode
}) {
const [collapsed, setCollapsed] = useState(false)
return (
<aside className={collapsed ? 'w-16' : 'w-64'}>
<button onClick={() => setCollapsed(!collapsed)}>
Toggle
</button>
<div className="p-4">
<p>{usuario.nombre}</p>
{children} {/* Server Component renderizado */}
</div>
</aside>
)
}
// components/NavegacionServidor.tsx (Server Component)
import { obtenerMenuItems } from '@/lib/navigation'
export default async function NavegacionServidor() {
const items = await obtenerMenuItems() // Desde DB
return (
<nav>
{items.map(item => (
<a key={item.id} href={item.url}>
{item.label}
</a>
))}
</nav>
)
}
Ventajas:
- Sidebar interactivo (Client)
- Navegación con datos frescos del servidor
- Usuario autenticado desde el servidor
- Cero JavaScript extra para la navegación
Patrón 2: Modal con contenido dinámico
Caso de uso: Modal interactivo que muestra datos del servidor.
// app/productos/[id]/page.tsx (Server Component)
import ModalDetalles from '@/components/ModalDetalles'
import DetallesProducto from '@/components/DetallesProducto'
export default async function ProductoPage({
params
}: {
params: { id: string }
}) {
const producto = await obtenerProducto(params.id)
return (
<div>
<h1>{producto.nombre}</h1>
<ModalDetalles>
<DetallesProducto producto={producto} />
</ModalDetalles>
</div>
)
}
// components/ModalDetalles.tsx
'use client'
import { useState } from 'react'
export default function ModalDetalles({
children
}: {
children: React.ReactNode
}) {
const [isOpen, setIsOpen] = useState(false)
if (!isOpen) {
return (
<button onClick={() => setIsOpen(true)}>
Ver detalles
</button>
)
}
return (
<div className="modal">
<div className="modal-backdrop" onClick={() => setIsOpen(false)} />
<div className="modal-content">
<button onClick={() => setIsOpen(false)}>Cerrar</button>
{children} {/* Contenido del servidor */}
</div>
</div>
)
}
// components/DetallesProducto.tsx (Server Component)
export default async function DetallesProducto({
producto
}: {
producto: Producto
}) {
// Obtener datos adicionales del servidor
const especificaciones = await obtenerEspecificaciones(producto.id)
return (
<div>
<h2>{producto.nombre}</h2>
<ul>
{especificaciones.map(spec => (
<li key={spec.id}>{spec.nombre}: {spec.valor}</li>
))}
</ul>
</div>
)
}
Patrón 3: Tabs con contenido del servidor
Caso de uso: Pestañas interactivas donde cada pestaña carga datos del servidor.
// app/perfil/page.tsx (Server Component)
import TabsWrapper from '@/components/TabsWrapper'
import PerfilInfo from '@/components/PerfilInfo'
import PerfilPedidos from '@/components/PerfilPedidos'
import PerfilConfiguracion from '@/components/PerfilConfiguracion'
export default async function PerfilPage() {
const usuario = await obtenerUsuario()
return (
<TabsWrapper
tabs={[
{
id: 'info',
label: 'Información',
content: <PerfilInfo usuario={usuario} />
},
{
id: 'pedidos',
label: 'Pedidos',
content: <PerfilPedidos usuarioId={usuario.id} />
},
{
id: 'config',
label: 'Configuración',
content: <PerfilConfiguracion usuario={usuario} />
}
]}
/>
)
}
// components/TabsWrapper.tsx
'use client'
import { useState } from 'react'
interface Tab {
id: string
label: string
content: React.ReactNode
}
export default function TabsWrapper({
tabs
}: {
tabs: Tab[]
}) {
const [activeTab, setActiveTab] = useState(tabs[0].id)
return (
<div>
<div className="flex gap-2 border-b">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={activeTab === tab.id ? 'active' : ''}
>
{tab.label}
</button>
))}
</div>
<div className="p-4">
{tabs.find(tab => tab.id === activeTab)?.content}
</div>
</div>
)
}
Cada pestaña es un Server Component que obtiene sus propios datos.
Patrón 4: Formulario con validación client + guardado server
Caso de uso: Formulario con validación instantánea en el cliente y guardado en el servidor.
// app/productos/nuevo/page.tsx (Server Component)
import FormularioProducto from '@/components/FormularioProducto'
import { guardarProducto } from '@/actions/productos'
export default function NuevoProductoPage() {
return (
<div>
<h1>Crear Producto</h1>
<FormularioProducto onSubmit={guardarProducto} />
</div>
)
}
// components/FormularioProducto.tsx
'use client'
import { useState } from 'react'
export default function FormularioProducto({
onSubmit
}: {
onSubmit: (data: FormData) => Promise<void>
}) {
const [nombre, setNombre] = useState('')
const [precio, setPrecio] = useState('')
const [errores, setErrores] = useState<Record<string, string>>({})
const validar = () => {
const nuevosErrores: Record<string, string> = {}
if (nombre.length < 3) {
nuevosErrores.nombre = 'Mínimo 3 caracteres'
}
if (Number(precio) <= 0) {
nuevosErrores.precio = 'El precio debe ser mayor a 0'
}
setErrores(nuevosErrores)
return Object.keys(nuevosErrores).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validar()) return
const formData = new FormData()
formData.append('nombre', nombre)
formData.append('precio', precio)
await onSubmit(formData)
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={nombre}
onChange={(e) => {
setNombre(e.target.value)
validar() // Validación en tiempo real
}}
placeholder="Nombre del producto"
/>
{errores.nombre && <span className="error">{errores.nombre}</span>}
</div>
<div>
<input
value={precio}
onChange={(e) => {
setPrecio(e.target.value)
validar()
}}
placeholder="Precio"
type="number"
/>
{errores.precio && <span className="error">{errores.precio}</span>}
</div>
<button type="submit">Guardar</button>
</form>
)
}
// actions/productos.ts (Server Action)
'use server'
import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'
export async function guardarProducto(formData: FormData) {
const nombre = formData.get('nombre') as string
const precio = Number(formData.get('precio'))
// Validación del servidor (siempre necesaria)
if (!nombre || precio <= 0) {
throw new Error('Datos inválidos')
}
// Guardar en la base de datos
await db.producto.create({
data: { nombre, precio }
})
revalidatePath('/productos')
}
Ventajas:
- Validación instantánea (Client)
- Guardado seguro en servidor
- Base de datos no expuesta al cliente
Patrón 5: Lista con filtros interactivos
Caso de uso: Lista de productos con filtros que se aplican sin recargar.
// app/tienda/page.tsx (Server Component)
import FiltrosProductos from '@/components/FiltrosProductos'
import { obtenerProductos } from '@/lib/productos'
export default async function TiendaPage({
searchParams
}: {
searchParams: { categoria?: string; orden?: string }
}) {
const productos = await obtenerProductos({
categoria: searchParams.categoria,
orden: searchParams.orden
})
return (
<div className="flex gap-6">
<aside className="w-64">
<FiltrosProductos />
</aside>
<main className="flex-1">
<div className="grid grid-cols-3 gap-4">
{productos.map(producto => (
<ProductoCard key={producto.id} producto={producto} />
))}
</div>
</main>
</div>
)
}
// components/FiltrosProductos.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
export default function FiltrosProductos() {
const router = useRouter()
const searchParams = useSearchParams()
const aplicarFiltro = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(key, value)
router.push(`?${params.toString()}`)
}
return (
<div>
<h3>Categoría</h3>
<button onClick={() => aplicarFiltro('categoria', 'ropa')}>
Ropa
</button>
<button onClick={() => aplicarFiltro('categoria', 'electronica')}>
Electrónica
</button>
<h3>Ordenar por</h3>
<button onClick={() => aplicarFiltro('orden', 'precio-asc')}>
Precio: Menor a Mayor
</button>
<button onClick={() => aplicarFiltro('orden', 'precio-desc')}>
Precio: Mayor a Menor
</button>
</div>
)
}
Los filtros son interactivos (Client) pero los productos se obtienen del servidor con los filtros aplicados.
Múltiples props además de children
No estás limitado a children
. Puedes pasar múltiples Server Components:
// app/dashboard/page.tsx (Server Component)
import Layout from '@/components/Layout'
import Sidebar from '@/components/Sidebar'
import Header from '@/components/Header'
import Content from '@/components/Content'
export default async function DashboardPage() {
const usuario = await obtenerUsuario()
const stats = await obtenerEstadisticas()
return (
<Layout
header={<Header usuario={usuario} />}
sidebar={<Sidebar usuario={usuario} />}
footer={<Footer />}
>
<Content stats={stats} />
</Layout>
)
}
// components/Layout.tsx
'use client'
export default function Layout({
header,
sidebar,
children,
footer
}: {
header: React.ReactNode
sidebar: React.ReactNode
children: React.ReactNode
footer: React.ReactNode
}) {
return (
<div className="layout">
{header}
<div className="flex">
{sidebar}
<main>{children}</main>
</div>
{footer}
</div>
)
}
Todos los Server Components se ejecutan en el servidor y se pasan como HTML renderizado.
Contexto y composición
Context API funciona diferente con Server/Client Components:
Context solo en Client Components
// contexts/CarritoContext.tsx
'use client'
import { createContext, useContext, useState } from 'react'
const CarritoContext = createContext<CarritoContextType | null>(null)
export function CarritoProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<Item[]>([])
return (
<CarritoContext.Provider value={{ items, setItems }}>
{children}
</CarritoContext.Provider>
)
}
export function useCarrito() {
const context = useContext(CarritoContext)
if (!context) throw new Error('useCarrito debe usarse dentro de CarritoProvider')
return context
}
// app/layout.tsx (Server Component)
import { CarritoProvider } from '@/contexts/CarritoContext'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<CarritoProvider>
{children} {/* Server Components dentro del Provider */}
</CarritoProvider>
</body>
</html>
)
}
// components/BotonAgregarCarrito.tsx
'use client'
import { useCarrito } from '@/contexts/CarritoContext'
export default function BotonAgregarCarrito({ producto }: { producto: Producto }) {
const { setItems } = useCarrito() // Funciona porque es Client Component
return (
<button onClick={() => setItems(prev => [...prev, producto])}>
Agregar al carrito
</button>
)
}
Context solo funciona en Client
No puedes usar Context API en Server Components. Si necesitas compartir datos entre Server Components, pásalos como props o usa una fuente externa (base de datos, API).
Errores comunes y soluciones
Error 1: Importar Server Component en Client
// ❌ INCORRECTO
'use client'
import ServerComponent from './ServerComponent'
export default function ClientComponent() {
return <ServerComponent />
}
Solución: Usa el patrón children
// ✓ CORRECTO
// Parent.tsx (Server Component)
import ClientComponent from './ClientComponent'
import ServerComponent from './ServerComponent'
export default function Parent() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
// ClientComponent.tsx
'use client'
export default function ClientComponent({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
Error 2: Usar hooks en Server Component
// ❌ INCORRECTO
import { useState } from 'react'
export default async function ProductosPage() {
const [filtro, setFiltro] = useState('') // Error: hooks no funcionan en Server
const productos = await obtenerProductos()
return <div>...</div>
}
¿Qué son los hooks?
Los hooks (como useState
, useEffect
, useContext
) son funciones especiales de React que solo funcionan en Client Components. Permiten agregar interactividad y gestionar estado.
Solución: Extrae la parte interactiva a un Client Component
// ✓ CORRECTO
// page.tsx (Server Component)
import Filtros from '@/components/Filtros'
export default async function ProductosPage() {
const productos = await obtenerProductos()
return (
<div>
<Filtros />
<ProductosList productos={productos} />
</div>
)
}
// Filtros.tsx
'use client'
import { useState } from 'react'
export default function Filtros() {
const [filtro, setFiltro] = useState('')
// ...
}
Error 3: Intentar usar Context en Server Component
// ❌ INCORRECTO
import { useUsuario } from '@/contexts/UsuarioContext'
export default async function ProfilePage() {
const usuario = useUsuario() // Error: no puedes usar hooks en Server
return <div>{usuario.nombre}</div>
}
Solución: Obtén los datos directamente en el servidor
// ✓ CORRECTO
import { obtenerUsuario } from '@/lib/auth'
export default async function ProfilePage() {
const usuario = await obtenerUsuario() // Desde el servidor
return <div>{usuario.nombre}</div>
}
Error 4: Pasar funciones a Server Components
// ❌ INCORRECTO
'use client'
import ServerComponent from './ServerComponent'
export default function ClientComponent() {
const handleClick = () => console.log('clicked')
// No puedes pasar funciones a Server Components
return <ServerComponent onClick={handleClick} />
}
Solución: El componente que necesita la función debe ser Client Component
// ✓ CORRECTO
// Parent.tsx (Server Component)
import ClientButton from './ClientButton'
export default async function Parent() {
const data = await obtenerDatos()
return (
<div>
<p>{data.texto}</p>
<ClientButton /> {/* Client Component maneja interactividad */}
</div>
)
}
// ClientButton.tsx
'use client'
export default function ClientButton() {
const handleClick = () => console.log('clicked')
return <button onClick={handleClick}>Click</button>
}
Mejores prácticas
1. Componentes Client lo más pequeños posible
// ❌ Malo: Todo el componente es Client
'use client'
export default function ProductoPage() {
const [cantidad, setCantidad] = useState(1)
return (
<div>
<Header />
<ProductoImagen />
<ProductoInfo />
<ProductoDescripcion />
<input
value={cantidad}
onChange={(e) => setCantidad(Number(e.target.value))}
/>
<RelatedProducts />
</div>
)
}
// ✓ Bueno: Solo lo interactivo es Client
export default function ProductoPage() {
return (
<div>
<Header /> {/* Server */}
<ProductoImagen /> {/* Server */}
<ProductoInfo /> {/* Server */}
<ProductoDescripcion /> {/* Server */}
<SelectorCantidad /> {/* Client - solo esto */}
<RelatedProducts /> {/* Server */}
</div>
)
}
2. Pasar datos serializables
// ❌ Malo: Pasar funciones o clases
<ClientComponent
onClick={() => console.log('click')} // Función
date={new Date()} // Clase
/>
// ✓ Bueno: Solo datos serializables
<ClientComponent
label="Click aquí" // String
timestamp={Date.now()} // Number
config={{ enabled: true }} // Object plano
/>
3. Usa Server Actions para mutaciones
Las Server Actions (acciones del servidor) son funciones que se ejecutan en el servidor y permiten modificar datos de forma segura:
// ✓ Bueno: Server Action para guardar
'use server'
export async function crearProducto(formData: FormData) {
const nombre = formData.get('nombre') as string
await db.producto.create({ data: { nombre } })
revalidatePath('/productos')
}
// components/FormularioProducto.tsx
'use client'
import { crearProducto } from '@/actions/productos'
export default function FormularioProducto() {
return (
<form action={crearProducto}>
<input name="nombre" />
<button type="submit">Crear</button>
</form>
)
}
4. Layout pattern para composición
// app/layout.tsx
import Providers from '@/components/Providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>
{children}
</Providers>
</body>
</html>
)
}
// components/Providers.tsx
'use client'
import { CarritoProvider } from '@/contexts/CarritoContext'
import { UsuarioProvider } from '@/contexts/UsuarioContext'
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<CarritoProvider>
<UsuarioProvider>
{children}
</UsuarioProvider>
</CarritoProvider>
)
}
Todos los contexts en un solo componente Client, el resto puede ser Server.
5. Composición clara y predecible
// ✓ Bueno: Estructura clara
app/
├── layout.tsx (Server)
│ └── Providers.tsx (Client - wraps everything)
│
├── page.tsx (Server)
│ ├── Header.tsx (Server)
│ ├── Filtros.tsx (Client - interactivo)
│ └── ProductosList.tsx (Server)
│ └── ProductoCard.tsx (Server)
│ └── BotonComprar.tsx (Client - interactivo)
Visualmente clara dónde están los límites Server/Client.
Ejemplo completo: E-commerce
Estructura completa con composición correcta:
// app/productos/[slug]/page.tsx (Server Component)
import { notFound } from 'next/navigation'
import ProductoImagenes from '@/components/ProductoImagenes'
import ProductoInfo from '@/components/ProductoInfo'
import ProductoDescripcion from '@/components/ProductoDescripcion'
import ProductosRelacionados from '@/components/ProductosRelacionados'
import ControlesProducto from '@/components/ControlesProducto'
import { obtenerProducto } from '@/lib/productos'
export default async function ProductoPage({
params
}: {
params: { slug: string }
}) {
const producto = await obtenerProducto(params.slug)
if (!producto) {
notFound()
}
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Columna izquierda - Server Component */}
<ProductoImagenes imagenes={producto.imagenes} />
{/* Columna derecha */}
<div>
{/* Info básica - Server Component */}
<ProductoInfo
nombre={producto.nombre}
precio={producto.precio}
disponible={producto.stock > 0}
/>
{/* Controles interactivos - Client Component */}
<ControlesProducto
productoId={producto.id}
stock={producto.stock}
variantes={producto.variantes}
/>
{/* Descripción - Server Component */}
<ProductoDescripcion descripcion={producto.descripcion} />
</div>
</div>
{/* Productos relacionados - Server Component */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Productos relacionados</h2>
<ProductosRelacionados categoriaId={producto.categoriaId} />
</div>
</div>
)
}
// components/ControlesProducto.tsx (Client Component)
'use client'
import { useState } from 'react'
import { agregarAlCarrito } from '@/actions/carrito'
interface Variante {
id: string
nombre: string
disponible: boolean
}
export default function ControlesProducto({
productoId,
stock,
variantes
}: {
productoId: string
stock: number
variantes: Variante[]
}) {
const [cantidad, setCantidad] = useState(1)
const [varianteId, setVarianteId] = useState(variantes[0]?.id)
const [agregando, setAgregando] = useState(false)
const handleAgregarCarrito = async () => {
setAgregando(true)
try {
await agregarAlCarrito({
productoId,
varianteId,
cantidad
})
alert('Producto agregado al carrito')
} catch (error) {
alert('Error al agregar producto')
} finally {
setAgregando(false)
}
}
return (
<div className="space-y-4">
{/* Selector de variante */}
<div>
<label className="block text-sm font-medium mb-2">
Variante
</label>
<select
value={varianteId}
onChange={(e) => setVarianteId(e.target.value)}
className="w-full border rounded px-3 py-2"
>
{variantes.map(variante => (
<option
key={variante.id}
value={variante.id}
disabled={!variante.disponible}
>
{variante.nombre}
</option>
))}
</select>
</div>
{/* Selector de cantidad */}
<div>
<label className="block text-sm font-medium mb-2">
Cantidad
</label>
<div className="flex items-center gap-2">
<button
onClick={() => setCantidad(Math.max(1, cantidad - 1))}
className="px-3 py-1 border rounded"
>
-
</button>
<input
type="number"
value={cantidad}
onChange={(e) => setCantidad(Number(e.target.value))}
min="1"
max={stock}
className="w-20 text-center border rounded px-3 py-1"
/>
<button
onClick={() => setCantidad(Math.min(stock, cantidad + 1))}
className="px-3 py-1 border rounded"
>
+
</button>
<span className="text-sm text-gray-600">
({stock} disponibles)
</span>
</div>
</div>
{/* Botón de agregar */}
<button
onClick={handleAgregarCarrito}
disabled={agregando || stock === 0}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold disabled:bg-gray-400"
>
{agregando ? 'Agregando...' : 'Agregar al carrito'}
</button>
</div>
)
}
// actions/carrito.ts (Server Action)
'use server'
import { cookies } from 'next/headers'
import { db } from '@/lib/database'
export async function agregarAlCarrito({
productoId,
varianteId,
cantidad
}: {
productoId: string
varianteId: string
cantidad: number
}) {
const sessionId = cookies().get('session')?.value
if (!sessionId) {
throw new Error('No autenticado')
}
// Validar stock
const producto = await db.producto.findUnique({
where: { id: productoId }
})
if (!producto || producto.stock < cantidad) {
throw new Error('Stock insuficiente')
}
// Agregar al carrito
await db.carritoItem.create({
data: {
sessionId,
productoId,
varianteId,
cantidad
}
})
}
Estructura:
- Página principal: Server Component (obtiene datos)
- Imágenes y descripción: Server Components (solo muestran)
- Controles: Client Component (interactividad)
- Agregar al carrito: Server Action (seguridad)
Resumen
Reglas de composición:
- Server puede renderizar Client y Server
- Client puede renderizar Client
- Client NO puede importar Server
- Usa
children
y props para pasar Server Components a Client - Context solo funciona en Client Components
- Mantén Client Components pequeños y enfocados
- Usa Server Actions para mutaciones seguras
Tabla de decisión:
Necesitas | Ubicación | Patrón |
---|---|---|
Interactividad | Client Component | Componente pequeño y específico |
Data fetching | Server Component | async function con await |
Compartir estado cliente | Context en Client | Provider en layout |
Layout interactivo | Client con children | Pasar Server como children |
Guardar datos | Server Action | 'use server' function |
Validación tiempo real | Client Component | useState + validación |
Validación final | Server Action | Validar antes de guardar |