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:

  1. Page (Server) renderiza ClientWrapper
  2. Page pasa ServerData como children
  3. ServerData se ejecuta en el servidor
  4. ClientWrapper recibe el HTML ya renderizado
  5. ClientWrapper 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:

  1. Server puede renderizar Client y Server
  2. Client puede renderizar Client
  3. Client NO puede importar Server
  4. Usa children y props para pasar Server Components a Client
  5. Context solo funciona en Client Components
  6. Mantén Client Components pequeños y enfocados
  7. Usa Server Actions para mutaciones seguras

Tabla de decisión:

NecesitasUbicaciónPatrón
InteractividadClient ComponentComponente pequeño y específico
Data fetchingServer Componentasync function con await
Compartir estado clienteContext en ClientProvider en layout
Layout interactivoClient con childrenPasar Server como children
Guardar datosServer Action'use server' function
Validación tiempo realClient ComponentuseState + validación
Validación finalServer ActionValidar antes de guardar