Skip to content

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 ejecutaSólo en el servidorServidor (HTML inicial) y navegador
JavaScript enviado al navegadorNingunoSí
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-only
ts
// 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 secretosServer
Mostrar contenido estático (textos, listas, tarjetas)Server
Reducir el JavaScript enviadoServer
useState, useEffect, useReducer, contextoClient
Eventos: clics, inputs, formularios interactivosClient
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: usaste window o localStorage durante el render. Muévelo a useEffect o usa dynamic con ssr: 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 ​

  1. Crea una página /clima (Server Component) que muestre una lista de 5 ciudades, y un Client Component Favorito con una estrella que se pueda marcar en cada ciudad.
  2. 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 ser async).
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 con server-only.

Academia Web — aprende, copia y construye.