Módulo 05: Server y Client Components

Objetivos del módulo

  • Entender que una aplicación de Next.js tiene dos grafos de módulos, uno para el servidor y otro para el cliente, y que 'use client' marca la frontera entre ellos
  • Saber qué puede hacer un Server Component y qué no, y reconocer al instante los errores que da Next cuando lo olvidas
  • Colocar 'use client' donde toca: al principio del fichero, solo en los puntos de entrada y lo más abajo posible del árbol
  • Leer el RSC Payload real de una página y comprobar qué código y qué datos llegan de verdad al navegador
  • Conocer con exactitud qué tipos de datos se pueden pasar como props de un Server Component a un Client Component, y cuáles no
  • Componer Server y Client Components con children y con props que contienen JSX, distinguiendo propietario y padre
  • Pasar promesas al cliente y leerlas con use() para mostrar datos en cuanto llegan
  • Usar contexto de React en una aplicación con Server Components, incluso con datos que vienen del servidor
  • Proteger módulos con server-only y client-only
  • Integrar bibliotecas de terceros que no declaran 'use client' y evitar la trampa de los componentes compuestos
  • Diagnosticar los errores típicos de la frontera a partir del mensaje real que muestra Next.js 16

💡 Consejo: El módulo 23 del Curso de React presenta los Server Components de forma general: qué son, por qué no son lo mismo que SSR y cómo se lee un payload. Aquí no lo repetimos todo: damos por sabida la idea y bajamos al detalle de cómo lo implementa Next.js: los grafos de módulos que construye Turbopack, los mensajes de error exactos, lo que viaja en el HTML y las decisiones de diseño que tomarás en cada pantalla de la aplicación EMPRESA.


1. Dónde se ejecuta cada componente

En el App Router, todo componente es un Server Component mientras no digas lo contrario. Las páginas (page.tsx), los layouts (layout.tsx) y cualquier componente que importen se ejecutan en el servidor. Solo cuando un fichero empieza por 'use client' sus componentes pasan a ser Client Components.

El nombre engaña un poco. “Client Component” no significa “componente que solo se ejecuta en el navegador”. Significa “componente cuyo código también viaja al navegador”:

  En el servidor En el navegador
Server Component Sí No, nunca llega su código
Client Component Sí, para generar el HTML inicial Sí, para hidratarlo y reaccionar a eventos

Un Client Component se ejecuta en el servidor en el prerender (o en cada petición, si la ruta es dinámica) para producir HTML, y otra vez en el navegador para la hidratación, el paso en el que React engancha los manejadores de eventos al HTML que ya existe. La documentación de Next lo resume con un ejemplo que puedes probar tú mismo:

'use client'

export default function Hola() {
  console.log('Hola renderizado') // visita directa: terminal del servidor y consola del navegador
  return <p>Hola</p>
}

En una visita directa (escribes la URL o recargas) verás el mensaje en los dos sitios. En una navegación con <Link> solo lo verás en el navegador: el servidor envía el payload y el componente se renderiza directamente en el cliente, sin HTML del servidor.

📘 Concepto: “Renderizado en el servidor” y “Server Component” son cosas distintas. La primera describe cómo se produjo el HTML (en el build, en cada petición…). La segunda describe dónde vive el código del componente y si viaja o no al navegador. Un Client Component puede estar renderizado en el servidor; un Server Component nunca está en el navegador.

¿Y por qué tanto empeño en que los componentes se queden en el servidor? Por tres motivos que verás demostrados en este módulo:

  1. Menos JavaScript. El código de un Server Component no se descarga ni se ejecuta en el navegador. Una tabla de 24 empleados generada en el servidor cuesta cero bytes de JS.
  2. Acceso directo a los datos. Un Server Component puede ser async, hacer await fetch(...) a la API EMPRESA, leer un fichero o consultar la base de datos (módulo 16), y usar secretos como tokens o claves sin exponerlos.
  3. Primera pintura rápida. El HTML llega hecho y, con streaming, por partes, como viste en el módulo 02.

El precio es que un Server Component no puede tener estado ni responder a eventos: no hay useState, ni useEffect, ni onClick. Para eso están los Client Components. El arte está en combinarlos.


2. Dos grafos de módulos y una frontera

Para entender de verdad lo que pasa, olvida un momento los componentes y piensa en ficheros y en import. Turbopack recorre tus importaciones a partir de cada página y construye dos grafos:

  • El grafo del servidor: todo lo que se ejecuta solo en Node.js. Empieza en layout.tsx y page.tsx.
  • El grafo del cliente: todo lo que debe empaquetarse para el navegador. Empieza en cada fichero marcado con 'use client'.

Diagrama con el grafo del servidor a la izquierda (layout, page, lib/api.ts con server-only) y el grafo del cliente a la derecha (boton-tema y filtro-empleados con use client, fila-empleado por importación), separados por una línea discontinua; un módulo de tipos compartido aparece en medio

Figura 1. La directiva 'use client' traza una frontera entre los dos grafos. Lo que cruza es una referencia y unas props serializadas, nunca el código de un Server Component.

Dos reglas deciden qué cruza la frontera:

  • El código cruza por las importaciones. Todo lo que importa un fichero 'use client' entra en el paquete del navegador, tenga directiva o no. En la figura, fila-empleado.tsx no tiene 'use client', pero como lo importa filtro-empleados.tsx es parte del grafo del cliente.
  • Los datos cruzan por las props. Cuando un Server Component renderiza un Client Component, no le pasa funciones ni objetos vivos: le pasa datos serializados, convertidos a un formato de texto que React sabe reconstruir en el navegador. Lo verás con detalle en el apartado 6.

Un mismo fichero puede estar en los dos grafos a la vez. Si lib/tipos.ts o un lib/formato.ts con funciones de formato se importa desde un Server Component y también desde un Client Component, Next lo compila dos veces, una para cada entorno. Es normal y no tiene coste en el navegador más allá del propio módulo.

⚠️ Importante: El grafo del cliente nunca importa el grafo del servidor. Si un fichero 'use client' importa algo que solo puede vivir en el servidor (un módulo con server-only, el acceso a la base de datos…), el build falla. Y si importa un fichero sin directiva que contiene un Server Component, ese componente deja de ser de servidor: pasa a ser cliente, con todas sus limitaciones. Lo verás en el apartado 7.

Esta forma de pensar, por ficheros y no por componentes, resuelve casi todas las dudas:

Pregunta Respuesta
¿Tengo que poner 'use client' en todos los componentes interactivos? No. Solo en los ficheros que se importan desde un Server Component (los puntos de entrada). Lo que importan ya es cliente.
¿Un componente sin directiva es de servidor? Depende de quién lo importe. Si lo importa un Server Component, sí. Si lo importa un Client Component, es cliente.
¿Puede un Client Component mostrar un Server Component? Sí, si lo recibe ya renderizado como prop (children u otra). No, si lo importa.
¿Puede un Server Component mostrar un Client Component? Sí, siempre: es lo normal.

3. Lo que un Server Component puede y no puede hacer

Este es un Server Component típico de la aplicación EMPRESA. No tiene nada especial: no lleva directiva, es async y hace await.

// app/departamentos/resumen/page.tsx (Server Component)
const API = process.env.API_URL ?? 'http://localhost:3000/api'

export default async function ResumenDepartamentos() {
  const respuesta = await fetch(`${API}/departamentos`)
  const { contenido } = await respuesta.json()
  return <p>Hay {contenido.length} departamentos.</p>
}

Puede hacer cosas que un componente de React “de toda la vida” no puede:

Puede Por qué
Ser async y usar await React espera la promesa en el servidor antes de enviar su resultado
Leer variables de entorno sin NEXT_PUBLIC_ Se ejecuta en Node; el valor nunca llega al navegador
Usar módulos de Node (fs, crypto) o un cliente de base de datos Su código no se empaqueta para el navegador
Importar bibliotecas pesadas (Markdown, resaltado de código) Solo pesan en el servidor; al navegador llega el HTML
Leer cookies() y headers() Son APIs de la petición, que solo existen en el servidor

Y no puede hacer lo que depende de que el código esté vivo en el navegador:

No puede Error que verás Solución
useState, useReducer, useEffect, useRef… “You’re importing a module that depends on useState into a React Server Component module” Llevar esa parte a un Client Component
Manejadores de eventos (onClick, onChange…) “Event handlers cannot be passed to Client Component props” Ídem
createContext “You’re importing a module that depends on createContext…” Crear el proveedor en un fichero 'use client' (apartado 9)
APIs del navegador (window, localStorage) window is not defined en tiempo de ejecución Client Component, y dentro de useEffect o un manejador

3.1. Un hook en un Server Component

Prueba a escribir un contador en una página, olvidando la directiva:

// app/errores/hook/page.tsx
import { useState } from 'react'

export default function Pagina() {
  const [n, setN] = useState(0)
  return <p>{n}</p>
}

Next no espera a ejecutar el código: el compilador lo detecta al construir el grafo, porque sabe que useState es una API exclusiva del cliente. Este es el mensaje real de next dev (el build da el mismo):

⨯ ./app/errores/hook/page.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server
Component module. This API is only available in Client Components. To fix, mark
the file (or its parent) with the `"use client"` directive.
    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 1 | import { useState } from 'react'
    |          ^^^^^^^^
  2 |
  3 | export default function Pagina() {
  4 |   const [n, setN] = useState(0)
Ecmascript file had an error

Fíjate en la expresión “the file (or its parent)”: la directiva puede estar en ese fichero o en el fichero que lo importa, porque lo que importa un Client Component ya es cliente.

⚠️ Importante: En desarrollo, un error de compilación en una ruta puede tumbar también las demás: Turbopack informa de los errores de todo el grafo y Next muestra la página de error en cualquier URL que pidas hasta que lo arreglas. Si de pronto “todo” da 500, mira la terminal: el error suele estar en otro fichero.

3.2. Un onClick en un Server Component

Este error es más sutil, porque button no es un componente tuyo:

// app/errores/evento/page.tsx
export default function Pagina() {
  return (
    <button type="button" onClick={() => console.log('pulsado')}>
      Avisar
    </button>
  )
}

Esta vez compila: el compilador no prohíbe escribir onClick. El error salta al renderizar, cuando React intenta serializar el botón y se encuentra una función:

⨯ Error: Event handlers cannot be passed to Client Component props.
  <button type="button" onClick={function onClick} children=...>
                                ^^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

El mensaje dice “Client Component props” porque, para el payload, un elemento HTML como <button> se trata igual que un Client Component: es algo que el navegador tendrá que crear con esas props. Y una función no se puede enviar por la red.

3.3. Interactividad sin Client Components

Antes de añadir 'use client' por reflejo, pregúntate si el navegador ya sabe hacer lo que quieres. Hay interactividad que no necesita JavaScript propio:

  • <details> y <summary> abren y cierran contenido.
  • <a href> y <Link> navegan (Link es un Client Component de Next, pero ya viene hecho: lo usas desde un Server Component sin más).
  • <form action={...}> envía datos a una Server Action, que verás en el módulo 10.
  • <video controls> reproduce y pausa, y en CSS tienes :hover, :focus-within o :target para efectos que no necesitan estado.
// Un desplegable sin una sola línea de JavaScript propio
export default function Ayuda() {
  return (
    <details>
      <summary>¿Cómo se calcula el presupuesto?</summary>
      <p>Es la suma de los presupuestos de todos los departamentos.</p>
    </details>
  )
}

💡 Consejo: Usa un Client Component cuando haya estado que cambia con el tiempo: un campo controlado, un filtro en vivo, una pestaña activa, un arrastre. Un botón que solo navega o un formulario que solo envía no lo necesitan.


4. La directiva 'use client' a fondo

4.1. Dónde se escribe

'use client' es una cadena literal suelta en la primera línea del fichero, antes de cualquier import. Puede ir con comillas simples o dobles, y puede tener comentarios encima, pero ninguna otra sentencia. Si la pones después de un import, no se ignora en silencio: Next da error.

// app/errores/directiva/contador.tsx  (MAL)
import { useState } from 'react'
'use client'

export function Contador() {
  const [n, setN] = useState(0)
  return <button onClick={() => setN(n + 1)}>{n}</button>
}
⨯ ./app/errores/directiva/contador.tsx:2:1
Error: The "use client" directive must be placed before other expressions. Move
it to the top of the file to resolve this issue.
  1 | import { useState } from 'react'
> 2 | 'use client'
    | ^^^^^^^^^^^^

Justo después, Next informa también del segundo error, el de useState en un Server Component, porque al estar mal colocada la directiva el fichero sigue siendo de servidor.

La directiva afecta al módulo entero. No se puede poner dentro de una función para que “solo ese componente” sea de cliente: o es todo el fichero, o nada.

4.2. Solo en los puntos de entrada

Un fichero con 'use client' es una puerta de entrada al grafo del cliente. Las exportaciones de ese fichero son las que un Server Component puede renderizar. Lo que ese fichero importa ya está dentro, así que no necesita la directiva.

app/empleados/page.tsx              Server Component
└─ app/empleados/filtro-empleados.tsx   'use client'  ← punto de entrada
   └─ app/empleados/fila-empleado.tsx   sin directiva ← cliente por importación

¿Pasa algo si pones 'use client' en fila-empleado.tsx “por si acaso”? En este árbol, nada malo, pero conviertes ese fichero en un punto de entrada, y eso tiene una consecuencia: sus props pasan a tener que ser serializables, porque cualquier Server Component podría renderizarlo. Si FilaEmpleado recibe una función alSeleccionar desde FiltroEmpleados, no es un problema (las props entre Client Components no se serializan), pero el plugin de TypeScript de Next puede empezar a avisarte de que una función no puede ser prop de un punto de entrada. Pon la directiva donde de verdad empieza el cliente.

4.3. Los Client Components no pueden ser async

Si conviertes un Server Component en Client Component añadiendo la directiva, pero se te olvida quitar el async, el resultado es engañoso: la página parece funcionar.

// app/errores/async-cliente/lista.tsx  (MAL)
'use client'

export async function ListaAsincrona() {
  const respuesta = await fetch('http://localhost:3000/api/departamentos')
  const datos = await respuesta.json()
  return <p>{datos.total} departamentos</p>
}

La ruta responde 200 y muestra “7 departamentos”, pero la consola del navegador dice:

%s is an async Client Component. Only Server Components can be async at the
moment. This error is often caused by accidentally adding `'use client'` to a
module that was originally written for the server. <ListaAsincrona>

Y además, al cargar la página con Playwright esperando a que la red quedase en reposo (networkidle), la espera nunca terminaba: la página seguía haciendo peticiones. Un componente asíncrono en el cliente crea una promesa nueva en cada render, y React no sabe reutilizarla. No es un aviso que puedas ignorar. La solución depende de lo que querías:

  • Si el componente no necesita interactividad, quita 'use client': vuelve a ser un Server Component y el async es legal.
  • Si la necesita, carga los datos en un Server Component padre y pásalos como props, o pasa una promesa y léela con use() (apartado 8).
  • Si los datos dependen de lo que haga el usuario en el navegador, usa una biblioteca de datos en cliente como TanStack Query, que verás en el módulo 18.

4.4. 'use client' no es lo contrario de 'use server'

Es un error frecuente pensar que hay dos directivas simétricas: 'use client' para componentes de cliente y 'use server' para componentes de servidor. No es así.

Directiva Qué marca Cuándo se usa
(ninguna) Componentes de servidor Por defecto en el App Router
'use client' Un punto de entrada al grafo del cliente En el fichero de un componente interactivo
'use server' Funciones que el cliente puede invocar por la red (Server Functions) En un fichero de acciones o dentro de una función. Lo verás en el módulo 10

Si pones 'use server' en un fichero de componentes “para que sea de servidor”, lo que haces es convertir cada exportación en un punto de entrada público invocable desde el navegador. Nunca lo hagas con componentes.

4.5. El estado de los Client Components al navegar 🔄 16.0

🔄 Cambia en Next.js 16.0. Con cacheComponents activado (lo trae create-next-app desde 16.4), Next oculta con <Activity> las páginas que abandonas en lugar de desmontarlas (hasta 3), y al volver con <Link> sus Client Components conservan el estado. En Next.js 15 las páginas se desmontan y el estado se pierde. Si usas Next.js 15: guarda en la URL (searchParams) o en un contexto del layout el estado que deba sobrevivir a la navegación.

Este cambio, comprobado en el módulo 03, afecta a cómo diseñas tus Client Components. Si escribes “Ana” en el filtro de empleados, vas a Departamentos y vuelves con el enlace de la cabecera, en Next.js 16 el filtro sigue mostrando “Ana”. Casi siempre es lo que quiere el usuario. Si alguna vez no lo es (un formulario que debería aparecer vacío), tendrás que reiniciar ese estado tú, por ejemplo con una key que cambie.


5. Caso práctico: la lista de empleados con filtro

Vamos a construir la pantalla de empleados de EMPRESA siguiendo el reparto ideal: el servidor carga y prepara los datos, el cliente solo filtra. Necesitas la API EMPRESA en marcha (node server.mjs, en http://localhost:3000/api) y Next en otro puerto:

npm run dev -- -p 3001

5.1. La capa de acceso a la API

Todo el acceso a datos va en un módulo aparte, protegido con server-only (lo explicamos en el apartado 10). Así es imposible importarlo por error desde el cliente.

// lib/tipos.ts
export type Empleado = {
  empId: number
  nombre: string
  apellidos: string
  email: string
  telefono: string
  fechaAlta: string
  oficio: string
  salario: number
  comision: number | null
  jefeId: number | null
  deptId: number
}

export type Departamento = {
  deptId: number
  nombre: string
  localidad: string
  presupuesto: number
}

export type Pagina<T> = {
  contenido: T[]
  pagina: number
  tamano: number
  total: number
  paginas: number
}

// Lo único que necesita ver el navegador de cada empleado
export type EmpleadoResumen = Pick<Empleado, 'empId' | 'nombre' | 'apellidos' | 'oficio' | 'deptId'>
// lib/api.ts
import 'server-only'
import type { Departamento, Empleado, Pagina } from './tipos'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

async function pedir<T>(ruta: string): Promise<T> {
  const respuesta = await fetch(`${API}${ruta}`)
  if (!respuesta.ok) {
    throw new Error(`La API respondió ${respuesta.status} en ${ruta}`)
  }
  return respuesta.json() as Promise<T>
}

export async function obtenerEmpleados(): Promise<Empleado[]> {
  const pagina = await pedir<Pagina<Empleado>>('/empleados?tamano=100')
  return pagina.contenido
}

export async function obtenerDepartamentos(): Promise<Departamento[]> {
  const pagina = await pedir<Pagina<Departamento>>('/departamentos?tamano=100')
  return pagina.contenido
}

lib/tipos.ts no tiene directiva ni server-only: solo contiene tipos, que desaparecen al compilar, así que puede usarse desde los dos grafos.

5.2. La página (servidor) y el filtro (cliente)

// app/empleados/page.tsx
import { Suspense } from 'react'
import { obtenerEmpleados } from '@/lib/api'
import type { EmpleadoResumen } from '@/lib/tipos'
import { FiltroEmpleados } from './filtro-empleados'

// Server Component asíncrono: se ejecuta solo en el servidor
async function ListaEmpleados() {
  const empleados = await obtenerEmpleados()

  // Recortamos los datos: el navegador no necesita salario, email ni teléfono
  const resumen: EmpleadoResumen[] = empleados.map((e) => ({
    empId: e.empId,
    nombre: e.nombre,
    apellidos: e.apellidos,
    oficio: e.oficio,
    deptId: e.deptId,
  }))

  return <FiltroEmpleados empleados={resumen} />
}

export default function PaginaEmpleados() {
  return (
    <section>
      <h1>Empleados</h1>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <ListaEmpleados />
      </Suspense>
    </section>
  )
}
// app/empleados/filtro-empleados.tsx
'use client'

import { useState } from 'react'
import type { EmpleadoResumen } from '@/lib/tipos'

export function FiltroEmpleados({ empleados }: { empleados: EmpleadoResumen[] }) {
  const [texto, setTexto] = useState('')

  const visibles = empleados.filter((e) =>
    `${e.nombre} ${e.apellidos} ${e.oficio}`.toLowerCase().includes(texto.toLowerCase()),
  )

  return (
    <div>
      <label>
        Buscar:{' '}
        <input
          value={texto}
          onChange={(evento) => setTexto(evento.target.value)}
          placeholder="Nombre, apellidos u oficio"
        />
      </label>
      <p>
        {visibles.length} de {empleados.length} empleados
      </p>
      {visibles.length === 0 ? (
        <p>Sin coincidencias</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>Id</th>
              <th>Nombre</th>
              <th>Oficio</th>
              <th>Dpto.</th>
            </tr>
          </thead>
          <tbody>
            {visibles.map((e) => (
              <tr key={e.empId}>
                <td>{e.empId}</td>
                <td>
                  {e.nombre} {e.apellidos}
                </td>
                <td>{e.oficio}</td>
                <td>{e.deptId}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  )
}

Página de empleados con el campo de búsqueda que contiene la palabra director y una tabla con seis directores, de Ana García López a Javier López Navarro

Figura 2. El filtro funciona en el navegador sin volver a pedir nada al servidor: los 24 empleados ya llegaron en el payload.

Observa el reparto de responsabilidades:

  • ListaEmpleados carga (await a la API), recorta (solo cinco campos) y delega.
  • FiltroEmpleados no sabe de dónde vienen los datos: los recibe como props y solo guarda el texto del filtro.
  • PaginaEmpleados no es async: es un armazón estático con un hueco (Suspense) que se rellena cuando llegan los datos.

Este patrón, cargar en el servidor y pasar al cliente lo justo, es el que usarás en casi todas las pantallas del curso.

5.3. Datos sin caché y <Suspense> 🆕 16.0

🆕 Desde Next.js 16.0. Con la opción cacheComponents (activada por create-next-app 16.4), leer datos sin caché fuera de un <Suspense> hace fallar el build. No existe en Next.js 15. Si usas Next.js 15: el <Suspense> no es obligatorio; sin él la página compila como estática (○) con los datos congelados del build. El código de este apartado, con <Suspense>, compila igual en Next.js 15.

¿Por qué hemos separado ListaEmpleados de PaginaEmpleados en lugar de hacer async la página entera? Prueba a hacerlo:

// app/errores/sin-suspense/page.tsx
import { obtenerDepartamentos } from '@/lib/api'

export default async function Pagina() {
  const departamentos = await obtenerDepartamentos()
  return <p>{departamentos.length} departamentos</p>
}

next dev lo pinta, pero avisa con este mensaje real (y next build falla):

Error: Route "/errores/sin-suspense": Next.js encountered uncached data during prerendering.
`fetch(...)` or `connection()` accessed outside of `<Suspense>` prevents the route
from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] Cache the data access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
    at pedir (lib\api.ts:7:27)
    at obtenerDepartamentos (lib\api.ts:20:24)
    at Pagina (app\errores\sin-suspense\page.tsx:4:51)

Con Cache Components, Next quiere poder prerenderizar el armazón de cada página en el build y enviar después, por streaming, lo que depende de datos. Para eso necesita saber dónde están los huecos, y los huecos son los <Suspense>. La primera opción del mensaje (stream) es la que hemos usado; la segunda ('use cache') la verás en el módulo 09, y la carga de datos en profundidad en el módulo 08.

Este es el resultado real del build con todas las páginas de este módulo:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ◐ /departamentos
├ ◐ /empleados
├ ◐ /resumen
├ ○ /serializacion
└ ○ /terceros
○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

/empleados es ◐: el título y el fallback se generan en el build; la tabla, en cada petición. Compilamos /empleados, /resumen y /serializacion con el mismo código en el laboratorio de Next.js 15.5 y salen las tres como ○ (Static): allí los datos de la API se leen una vez, en el build, y quedan congelados hasta el siguiente.


6. Qué llega de verdad al navegador

Has leído muchas veces que “el código de los Server Components no llega al navegador”. Vamos a comprobarlo en el build de producción, y de paso a ver qué sí llega.

Diagrama en tres columnas: el servidor ejecuta los Server Components y produce el RSC Payload; el servidor usa el payload y los Client Components para generar HTML; el navegador recibe HTML, payload y el JavaScript de los Client Components, e hidrata

Figura 3. Lo que produce el servidor y en qué orden lo usa el navegador en una visita directa.

6.1. El RSC Payload dentro del HTML

En una visita directa, el HTML lleva incrustado el payload en etiquetas <script> con llamadas a self.__next_f.push(...). Este es un fragmento real de /empleados, descargado con curl y con las comillas ya desescapadas:

3:I[49710,["$2"],"ProveedorTema"]
5:I[8475,["$2"],"BotonTema"]
...
1f:"/_next/static/chunks/1rsqn-w_ezsyx.js"
20:I[14385,["$2","$1f"],"FiltroEmpleados"]
d:["$","$L20",null,{"empleados":[{"empId":100,"nombre":"Ana","apellidos":"García López","oficio":"DIRECTOR GENERAL","deptId":10},{"empId":101,"nombre":"Luis","apellidos":"Martínez Ruiz","oficio":"DIRECTOR","deptId":20},...

Cómo se lee:

  • Las líneas I[...] son referencias a Client Components: un identificador de módulo (14385), los ficheros JS que hay que descargar para tenerlo y el nombre de la exportación ("FiltroEmpleados"). Aquí aparecen los tres puntos de entrada del ejemplo: ProveedorTema, BotonTema y FiltroEmpleados.
  • La línea d: es el resultado de ListaEmpleados: un elemento ("$") cuyo tipo es la referencia $L20 (el Client Component FiltroEmpleados) y cuyas props son los datos serializados.
  • ListaEmpleados y PaginaEmpleados no aparecen por ningún lado. Del Server Component solo queda lo que devolvió.

6.2. Comprobado: qué hay en los ficheros JS

Tras next build, el JavaScript del navegador está en .next/static/chunks/. Buscando cadenas concretas se ve qué ha entrado:

Cadena buscada Dónde está en el código Ficheros de .next/static que la contienen
Sin coincidencias filtro-empleados.tsx (cliente) 1
La API respond lib/api.ts (servidor) 0
ListaEmpleados empleados/page.tsx (servidor) 0

El mensaje de error de lib/api.ts, la URL de la API y la función ListaEmpleados no están en ningún fichero que descargue el navegador. Puedes repetir la prueba en tu proyecto con cualquier buscador de texto sobre .next/static.

6.3. Lo que pasas como prop es público

La otra cara de la moneda: todo lo que pasas a un Client Component viaja al navegador y cualquiera puede leerlo con “Ver código fuente”. En el HTML de /empleados buscamos salario y @empresa.es: 0 apariciones, porque ListaEmpleados solo pasa cinco campos. Si hubiéramos escrito esto:

// MAL: el objeto completo viaja al navegador
return <FiltroEmpleados empleados={empleados} />

el payload llevaría el salario, el email y el teléfono de los 24 empleados, aunque la tabla no los muestre. El tipo EmpleadoResumen no es un capricho: es una barrera de seguridad que el compilador te ayuda a respetar.

⚠️ Importante: Ocultar un dato en la interfaz no es protegerlo. Si un dato no debe verlo el usuario, no lo pases a un Client Component, ni siquiera “para usarlo luego”. En el módulo 22 verás además la API taint de React, que hace fallar el render si un objeto marcado intenta cruzar la frontera.

Fíjate en una diferencia sutil. Si un Server Component pinta <p>Salario: {empleado.salario} €</p>, el número también llega al navegador, pero como texto ya renderizado, igual que llegaría en cualquier página HTML. Lo que no llega es el objeto empleado con todos sus campos. Lo comprobarás en el ejercicio 3.


7. Qué se puede pasar como prop

Las props de un Server Component a un Client Component tienen que ser serializables por React. El formato de React es mucho más rico que JSON, y conviene conocerlo con exactitud. Hemos montado una página que pasa un valor de cada tipo a un Client Component, y este muestra qué recibe en el navegador.

// app/serializacion/page.tsx
import { Suspense } from 'react'
import { Inspector } from './inspector'
import { saludar } from './acciones'

export default function PaginaSerializacion() {
  const formulario = new FormData()
  formulario.set('departamento', '20')

  const valores = {
    texto: 'Ana',
    numero: 6500,
    noEsNumero: NaN,
    infinito: Infinity,
    menosCero: -0,
    grande: BigInt('9007199254740993'),
    indefinido: undefined,
    nulo: null,
    fecha: new Date('2026-11-02T10:00:00Z'),
    mapa: new Map([[10, 'DIRECCIÓN']]),
    conjunto: new Set(['Madrid', 'Sevilla']),
    lista: [1, 2, 3],
    objeto: { deptId: 10, nombre: 'DIRECCIÓN' },
    bytes: new Uint8Array([1, 2, 3]),
    simbolo: Symbol.for('empresa'),
    formulario,
    promesa: Promise.resolve('resuelta en el servidor'),
    elemento: <em>JSX del servidor</em>,
    accion: saludar,
  }

  return (
    <section>
      <h1>Qué llega al cliente</h1>
      <Suspense fallback={<p>Esperando la promesa...</p>}>
        <Inspector valores={valores} />
      </Suspense>
    </section>
  )
}
// app/serializacion/acciones.ts
'use server'

export async function saludar(nombre: string) {
  return `Hola, ${nombre}, te saluda el servidor`
}
// app/serializacion/inspector.tsx
'use client'

import { use, useState, type ReactNode } from 'react'

function describir(clave: string, valor: unknown): string {
  // El JSX del servidor se renderiza, no se inspecciona
  if (clave === 'elemento') return 'JSX'
  if (valor === null) return 'null'
  if (typeof valor !== 'object') return typeof valor
  return valor.constructor?.name ?? 'objeto'
}

function mostrar(clave: string, valor: unknown): ReactNode {
  if (clave === 'elemento') return valor as ReactNode
  if (typeof valor === 'bigint') return `${valor}n`
  if (typeof valor === 'symbol') return valor.toString()
  if (typeof valor === 'function') return '(función)'
  if (Object.is(valor, -0)) return '-0'
  if (typeof valor === 'number') return String(valor)
  if (valor instanceof Date) return valor.toISOString()
  if (valor instanceof Map || valor instanceof Set) return JSON.stringify([...valor])
  if (valor instanceof Uint8Array) return `[${valor.join(', ')}]`
  if (valor instanceof FormData) return JSON.stringify([...valor.entries()])
  if (valor instanceof Promise) return '(promesa)'
  if (valor === undefined) return 'undefined'
  return JSON.stringify(valor) ?? String(valor)
}

export function Inspector({
  valores,
}: {
  valores: Record<string, unknown> & {
    promesa: Promise<string>
    accion: (nombre: string) => Promise<string>
  }
}) {
  const resultado = use(valores.promesa)
  const [saludo, setSaludo] = useState('')

  return (
    <div>
      <table>
        <thead>
          <tr>
            <th>Prop</th>
            <th>Tipo en el navegador</th>
            <th>Valor</th>
          </tr>
        </thead>
        <tbody>
          {Object.entries(valores).map(([clave, valor]) => (
            <tr key={clave}>
              <td>{clave}</td>
              <td>{describir(clave, valor)}</td>
              <td>{mostrar(clave, valor)}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p>
        <code>use(promesa)</code>: {resultado}
      </p>
      <p>
        <button type="button" onClick={async () => setSaludo(await valores.accion('Luis'))}>
          Llamar a la Server Function
        </button>{' '}
        {saludo}
      </p>
    </div>
  )
}

Tabla con una fila por prop: string, number con NaN, Infinity y -0, bigint, null, Date, Map, Set, Array, Object, Uint8Array, symbol, FormData, Promise, JSX y function; debajo, el valor de la promesa y la respuesta de la Server Function

Figura 4. Lo que recibe de verdad el Client Component en el navegador (build de producción, tras pulsar el botón).

7.1. Lo que sí cruza

Tipo Llega como Comentario
string, boolean, null Igual  
number Igual, incluidos NaN, Infinity y -0 JSON los convertiría en null o 0; React no
bigint bigint Con TypeScript, el literal 9007199254740993n no compila con el target ES2017 de create-next-app: usa BigInt('...')
undefined La clave no llega Al leerla obtienes undefined igualmente; 'indefinido' in valores da false
Date Date Un objeto Date de verdad, no una cadena
Map, Set Map, Set Con su contenido serializable
Arrays y objetos planos Igual Recursivamente, si su contenido es serializable
Uint8Array Uint8Array Útil para datos binarios pequeños
Symbol.for('...') El mismo símbolo global Solo símbolos registrados
FormData FormData  
Promise Promise Se lee con use(): apartado 8
JSX Elemento renderizable Se renderiza; no lo inspecciones (ver abajo)
Server Function ('use server') function Una referencia: al llamarla, se ejecuta en el servidor

7.2. Lo que no cruza, con el error real

Funciones normales. Es el error más frecuente: pasar un manejador desde el servidor.

// app/errores/funcion/page.tsx
import { BotonAviso } from './boton-aviso'

export default function Pagina() {
  return <BotonAviso alPulsar={() => console.log('pulsado')} />
}
⨯ Error: Functions cannot be passed directly to Client Components unless you
explicitly expose it by marking it with "use server". Or maybe you meant to call
this function rather than return it.
  <... alPulsar={function alPulsar}>
                ^^^^^^^^^^^^^^^^^^^

La solución es que la función nazca en el cliente: el manejador se escribe dentro de BotonAviso, y el servidor solo le pasa datos (un id, un texto). Si lo que querías era ejecutar algo en el servidor al pulsar, eso es una Server Action ('use server'), que sí cruza como referencia.

Instancias de clases. Un objeto creado con new de una clase propia no es un objeto plano: tiene un prototipo y métodos que no se pueden enviar.

// app/errores/clase/page.tsx
import { Ficha } from './ficha'

class EmpleadoModelo {
  constructor(
    public nombre: string,
    public salario: number,
  ) {}
  salarioAnual() {
    return this.salario * 14
  }
}

export default function Pagina() {
  return <Ficha empleado={new EmpleadoModelo('Ana', 6500)} />
}
⨯ Error: Only plain objects, and a few built-ins, can be passed to Client
Components from Server Components. Classes or null prototypes are not supported.
  <... empleado={{nombre: "Ana", salario: 6500}}>
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Pasa un objeto plano ({ nombre: e.nombre, salario: e.salario }) y, si el cliente necesita el salario anual, calcúlalo en el servidor y pásalo como número. Esto importa mucho con los ORM: las filas que devuelve Drizzle (módulo 16) son objetos planos, pero otras bibliotecas devuelven instancias de clase.

Otros casos que hemos comprobado:

Valor Mensaje real
Symbol('local') (no registrado) “Only global symbols received from Symbol.for(…) can be passed to Client Components. The symbol Symbol.for(local) cannot be found among global symbols.”
new URL('https://empresa.es') “Only plain objects can be passed to Client Components from Server Components. URL objects are not supported.” Pasa url.href.

7.3. El JSX llega, pero no es lo que parece

En la primera versión de Inspector intentamos describir la prop elemento con isValidElement(). El resultado fue un error de hidratación: en el servidor (SSR) era un elemento React normal, y en el navegador llegaba como un elemento perezoso. Un console.log(String(elemento.$$typeof)) en el navegador mostró:

tipo de elemento: Symbol(react.lazy)

React envía el JSX del servidor como una referencia que se resuelve al renderizar. La conclusión práctica: el JSX que recibes como prop se renderiza tal cual ({children}, {titulo}), no se inspecciona ni se recorre con Children.map buscando tipos o props. Si necesitas datos, pásalos como datos.


8. Composición: children, slots y quién es el dueño del JSX

La regla “un Client Component no puede importar un Server Component” parece una limitación seria: ¿cómo meto una tabla cargada en el servidor dentro de un modal, unas pestañas o un acordeón, que son de cliente? La respuesta es no importarla, sino recibirla ya renderizada.

8.1. Caso práctico: pestañas de departamentos

Queremos una pantalla con una pestaña por departamento. Las pestañas tienen estado (cuál está activa), así que son un Client Component. El contenido de cada pestaña (la plantilla del departamento) se carga en el servidor.

// app/departamentos/pestanas.tsx
'use client'

import { useState, type ReactNode } from 'react'

type Pestana = {
  id: number
  titulo: string
  contenido: ReactNode
}

export function Pestanas({ pestanas }: { pestanas: Pestana[] }) {
  const [activa, setActiva] = useState(pestanas[0]?.id)
  const actual = pestanas.find((p) => p.id === activa)

  return (
    <div>
      <div role="tablist" style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
        {pestanas.map((p) => (
          <button
            key={p.id}
            type="button"
            role="tab"
            aria-selected={p.id === activa}
            onClick={() => setActiva(p.id)}
          >
            {p.titulo}
          </button>
        ))}
      </div>
      <div role="tabpanel" className="panel">
        {actual?.contenido}
      </div>
    </div>
  )
}
// app/departamentos/page.tsx
import { Suspense } from 'react'
import { obtenerDepartamentos, obtenerEmpleados } from '@/lib/api'
import { Pestanas } from './pestanas'

// Server Component: recibe datos ya cargados y devuelve JSX
async function PlantillaDepartamento({ deptId }: { deptId: number }) {
  const empleados = (await obtenerEmpleados()).filter((e) => e.deptId === deptId)
  if (empleados.length === 0) {
    return <p>Este departamento no tiene empleados.</p>
  }
  return (
    <ul>
      {empleados.map((e) => (
        <li key={e.empId}>
          {e.nombre} {e.apellidos}, {e.oficio}
        </li>
      ))}
    </ul>
  )
}

async function ContenidoDepartamentos() {
  const departamentos = await obtenerDepartamentos()

  const pestanas = departamentos.map((d) => ({
    id: d.deptId,
    titulo: d.nombre,
    // JSX renderizado en el servidor que viaja como prop a un Client Component
    contenido: (
      <div>
        <p>
          <strong>{d.nombre}</strong> ({d.localidad})
        </p>
        <PlantillaDepartamento deptId={d.deptId} />
      </div>
    ),
  }))

  return <Pestanas pestanas={pestanas} />
}

export default function PaginaDepartamentos() {
  return (
    <section>
      <h1>Departamentos</h1>
      <Suspense fallback={<p>Cargando departamentos...</p>}>
        <ContenidoDepartamentos />
      </Suspense>
    </section>
  )
}

Página de departamentos con siete pestañas; la de VENTAS está seleccionada y debajo aparece la plantilla de Ventas en Madrid, con Luis Martínez Ruiz como director y cinco empleados más

Figura 5. Pestanas es un Client Component; el contenido de cada pestaña lo generó un Server Component.

PlantillaDepartamento es async y llama a la API, y aun así aparece dentro de un Client Component. No hay contradicción: Pestanas nunca importa PlantillaDepartamento. Lo que recibe en contenido es su resultado, ya calculado en el servidor.

💡 Consejo: obtenerEmpleados() se llama siete veces en este ejemplo, una por pestaña. En el módulo 08 verás que fetch deduplica peticiones idénticas durante un mismo render y cómo usar cache de React para tus propias funciones. Aquí nos interesa la composición.

8.2. Propietario y padre

React distingue dos papeles que en un árbol normal coinciden, pero aquí no:

  • El propietario (owner) de un elemento es el componente en cuyo código está escrito su JSX. ContenidoDepartamentos es el propietario de <Pestanas> y de <PlantillaDepartamento>.
  • El padre (parent) es el componente que lo contiene en el árbol renderizado. Pestanas es el padre del contenido de cada pestaña.

A la izquierda, el código de la página, propietaria de Pestanas y de Plantilla; a la derecha, el árbol renderizado, donde Pestanas (cliente) es el padre de Plantilla (servidor), que llega ya renderizada

Figura 6. Lo que decide dónde se ejecuta un componente es su propietario, no su padre.

Como el propietario de PlantillaDepartamento es un Server Component, se ejecuta en el servidor. Pestanas solo decide dónde y cuándo colocar algo que ya está hecho. La documentación de Next lo resume así: el Client Component “solo ve su resultado, nunca su código”.

8.3. El contenido oculto también viaja

Hay una consecuencia que no se ve a simple vista: todas las pestañas se renderizan en el servidor, aunque solo se vea una. Lo comprobamos en el HTML de /departamentos: “Medina Blanco”, del departamento 60 (LOGÍSTICA), aparece 1 vez en el documento, dentro del payload, y 0 veces en el panel visible. Cuando pulsas la pestaña, el contenido ya está en el navegador y aparece al instante, sin petición.

Es una decisión de diseño con dos caras:

Ventaja Inconveniente
Cambiar de pestaña es instantáneo Si hay muchas pestañas o mucho contenido, el payload crece
No hay estados de carga al cambiar El servidor trabaja aunque el usuario no abra nunca esa pestaña
  Lo que va en una pestaña oculta también es público

Si el contenido de cada pestaña fuese grande, la alternativa es que cada pestaña sea una ruta (/departamentos/20) y que la pestaña activa venga de la URL, como viste en el módulo 04. Así solo se carga la que se mira.

8.4. El patrón children

contenido es una prop cualquiera con JSX. El caso más habitual es children, que en JSX se escribe entre las etiquetas:

// app/ejercicios/desplegable/desplegable.tsx
'use client'

import { useState, type ReactNode } from 'react'

export function Desplegable({ titulo, children }: { titulo: string; children: ReactNode }) {
  const [abierto, setAbierto] = useState(false)
  return (
    <div className="panel">
      <button type="button" aria-expanded={abierto} onClick={() => setAbierto(!abierto)}>
        {abierto ? '▾' : '▸'} {titulo}
      </button>
      {abierto && children}
    </div>
  )
}
// Desde un Server Component
<Desplegable titulo="Departamentos">
  <Suspense fallback={<p>Cargando...</p>}>
    <TablaDepartamentos />
  </Suspense>
</Desplegable>

Lo verás completo en el ejercicio 5. Con este patrón construyes modales, acordeones, paneles laterales, layouts con estado… cualquier “caja interactiva con contenido de servidor dentro”.

8.5. Lo que no funciona: importar el Server Component

// MAL
'use client'

import { TablaDepartamentos } from './tabla-departamentos' // un componente async que hace fetch

export function Desplegable() {
  // ...
  return abierto && <TablaDepartamentos />
}

Al importarlo desde un fichero 'use client', TablaDepartamentos entra en el grafo del cliente. Ya no es un Server Component, sino un Client Component asíncrono, con el error del apartado 4.3. Si además importa lib/api.ts, con server-only, el build falla directamente (apartado 10). La regla práctica: un Client Component recibe Server Components, nunca los importa.

8.6. Empuja la frontera hacia las hojas

La decisión de diseño más importante del módulo es dónde empieza el cliente. Mira la cabecera de EMPRESA:

// app/layout.tsx (Server Component)
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'
import { ProveedorTema } from './_componentes/proveedor-tema'
import { BotonTema } from './_componentes/boton-tema'

export const metadata: Metadata = {
  title: 'EMPRESA',
  description: 'Server y Client Components',
}

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="min-h-full">
        <ProveedorTema>
          <header className="cabecera">
            <strong>EMPRESA</strong>
            <nav>
              <Link href="/empleados">Empleados</Link>
              <Link href="/departamentos">Departamentos</Link>
              <Link href="/serializacion">Serialización</Link>
              <Link href="/resumen">Resumen</Link>
            </nav>
            <BotonTema />
          </header>
          <main className="contenido">{children}</main>
        </ProveedorTema>
      </body>
    </html>
  )
}

Solo el botón de tema es interactivo, así que solo BotonTema es un Client Component. La tentación de principiante es poner 'use client' en el layout “porque hay un botón”, y eso convierte en cliente todo lo que el layout importa.

Comparación: a la izquierda la cabecera con use client arrastra Logo, Menú y BotonTema al cliente; a la derecha la cabecera es de servidor y solo BotonTema es un punto de entrada de cliente

Figura 7. Cuanto más abajo empieza el cliente, menos JavaScript y más componentes pueden leer datos en el servidor.

💡 Consejo: Cuando un componente grande tenga una sola parte interactiva, extrae esa parte a su propio fichero 'use client' y deja el resto en el servidor. Si la parte interactiva necesita envolver contenido, usa children.


9. Promesas que cruzan la frontera

En el apartado 7 viste que una Promise es serializable. Esto permite un patrón muy útil: el servidor empieza la petición, pero no la espera. Pasa la promesa al cliente, y el cliente la lee con use() dentro de un <Suspense>. El HTML del resto de la página sale ya, y los datos llegan por streaming en cuanto la API responde.

// app/resumen/page.tsx
import { Suspense } from 'react'
import { obtenerDepartamentos } from '@/lib/api'
import { ProveedorDepartamentos } from './proveedor-departamentos'
import { SelectorDepartamento, TotalPresupuesto } from './piezas'

export default function PaginaResumen() {
  // Sin await: la petición empieza ya y la promesa viaja al cliente
  const promesa = obtenerDepartamentos()

  return (
    <section>
      <h1>Resumen de departamentos</h1>
      <p>Este párrafo lo pinta el servidor y llega en el primer trozo de HTML.</p>
      <ProveedorDepartamentos promesa={promesa}>
        <Suspense fallback={<p>Calculando presupuesto...</p>}>
          <TotalPresupuesto />
          <SelectorDepartamento />
        </Suspense>
      </ProveedorDepartamentos>
    </section>
  )
}

Aquí hemos combinado dos ideas: la promesa no va como prop a cada componente, sino a un proveedor de contexto, y cualquier Client Component por debajo puede leerla. Es el patrón que la documentación de Next recomienda para “datos del servidor accesibles en todo un subárbol de cliente”.

// app/resumen/proveedor-departamentos.tsx
'use client'

import { createContext, use, type ReactNode } from 'react'
import type { Departamento } from '@/lib/tipos'

const ContextoDepartamentos = createContext<Promise<Departamento[]> | null>(null)

export function ProveedorDepartamentos({
  promesa,
  children,
}: {
  promesa: Promise<Departamento[]>
  children: ReactNode
}) {
  return <ContextoDepartamentos value={promesa}>{children}</ContextoDepartamentos>
}

export function useDepartamentos(): Departamento[] {
  const promesa = use(ContextoDepartamentos)
  if (!promesa) {
    throw new Error('useDepartamentos necesita <ProveedorDepartamentos>')
  }
  // use() suspende hasta que la promesa del servidor se resuelve
  return use(promesa)
}
// app/resumen/piezas.tsx
'use client'

import { useState } from 'react'
import { useDepartamentos } from './proveedor-departamentos'

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })

export function TotalPresupuesto() {
  const departamentos = useDepartamentos()
  const total = departamentos.reduce((suma, d) => suma + d.presupuesto, 0)
  return (
    <p>
      Presupuesto total de {departamentos.length} departamentos: <strong>{euros.format(total)}</strong>
    </p>
  )
}

export function SelectorDepartamento() {
  const departamentos = useDepartamentos()
  const [id, setId] = useState(departamentos[0]?.deptId)
  const elegido = departamentos.find((d) => d.deptId === id)

  return (
    <div className="panel">
      <select value={id} onChange={(e) => setId(Number(e.target.value))}>
        {departamentos.map((d) => (
          <option key={d.deptId} value={d.deptId}>
            {d.nombre}
          </option>
        ))}
      </select>
      {elegido && (
        <p>
          {elegido.localidad}: {euros.format(elegido.presupuesto)}
        </p>
      )}
    </div>
  )
}

Página de resumen en modo oscuro: el párrafo del servidor, el presupuesto total de 7 departamentos, 1.800.000,00 euros, y un selector con VENTAS elegido que muestra Madrid: 350.000,00 euros

Figura 8. Dos contextos a la vez: el del tema (modo oscuro activado con el botón de la cabecera) y el de los departamentos, alimentado con una promesa del servidor.

Puntos clave:

  • use() es la API de React 19 que lee una promesa o un contexto durante el render. A diferencia de los hooks, se puede llamar dentro de condicionales. Lo tienes explicado en el Curso de React.
  • Las dos piezas leen la misma promesa: la petición a la API se hace una sola vez.
  • El <Suspense> es obligatorio: mientras la promesa no se resuelve, use() “suspende” y React muestra el fallback.
  • PaginaResumen no es async ni hace await. Sale en ◐ en el build, igual que /empleados.

⚠️ Importante: La promesa tiene que crearse en un Server Component. Si la creas en un Client Component (const p = fetch(...) en el cuerpo del componente), se crea una promesa nueva en cada render y entras en un bucle. Para datos que dependen de lo que hace el usuario en el navegador, usa TanStack Query (módulo 18).

¿Cuándo pasar una promesa y cuándo esperar en el servidor y pasar los datos? Si el Client Component solo va a mostrar los datos, espera en un Server Component (más simple). Si quieres que el componente de cliente aparezca y sea interactivo antes de que lleguen los datos, o que varios componentes compartan la misma petición, pasa la promesa.


10. Contexto en una aplicación con Server Components

10.1. Los Server Components no tienen contexto

El contexto de React es un mecanismo de render en el cliente: un proveedor guarda un valor y los componentes de debajo lo leen al renderizar. Los Server Components no participan en él. Si intentas crear un contexto en un Server Component:

// app/errores/contexto/page.tsx  (MAL)
import { createContext } from 'react'

const ContextoUsuario = createContext('invitado')

export default function Pagina() {
  return (
    <ContextoUsuario value="ana">
      <p>Hola</p>
    </ContextoUsuario>
  )
}
⨯ ./app/errores/contexto/page.tsx:1:10
Error: You're importing a module that depends on `createContext` into a React
Server Component module. This API is only available in Client Components. To fix,
mark the file (or its parent) with the `"use client"` directive.

10.2. Proveedores como Client Components

La solución es la del apartado 8: un Client Component que recibe children. El proveedor de tema de EMPRESA:

// app/_componentes/proveedor-tema.tsx
'use client'

import { createContext, use, useState, type ReactNode } from 'react'

type Tema = 'claro' | 'oscuro'

type ValorTema = {
  tema: Tema
  alternar: () => void
}

const ContextoTema = createContext<ValorTema | null>(null)

export function ProveedorTema({ children }: { children: ReactNode }) {
  const [tema, setTema] = useState<Tema>('claro')
  const alternar = () => setTema((t) => (t === 'claro' ? 'oscuro' : 'claro'))

  return (
    <ContextoTema value={{ tema, alternar }}>
      <div className="tema" data-tema={tema}>
        {children}
      </div>
    </ContextoTema>
  )
}

export function useTema() {
  const valor = use(ContextoTema)
  if (!valor) {
    throw new Error('useTema debe usarse dentro de <ProveedorTema>')
  }
  return valor
}
// app/_componentes/boton-tema.tsx
'use client'

import { useTema } from './proveedor-tema'

export function BotonTema() {
  const { tema, alternar } = useTema()
  return (
    <button type="button" onClick={alternar}>
      {tema === 'claro' ? 'Modo oscuro' : 'Modo claro'}
    </button>
  )
}

RootLayout (servidor) renderiza <ProveedorTema> y le pasa como children la cabecera y la página, que siguen siendo Server Components. El proveedor no los convierte en cliente: los recibe ya renderizados. BotonTema, en cambio, sí es cliente y puede leer el contexto.

Tres detalles:

  • Desde React 19 el proveedor se escribe <ContextoTema value={...}>, sin .Provider, y el contexto se lee con use(Contexto). Funciona igual en el laboratorio de Next.js 15 (React 19.1).
  • Los Server Components de dentro no pueden leer el contexto, aunque estén “dentro” del proveedor. Si una página necesita saber el tema, ese dato tiene que venir de otro sitio (una cookie, por ejemplo).
  • La documentación aconseja colocar los proveedores lo más abajo posible del árbol: si solo la zona de gestión necesita un proveedor, ponlo en el layout de esa zona, no en el raíz.

💡 Consejo: Este tema “claro por defecto” tiene un problema: si el usuario prefiere el oscuro, verá un parpadeo hasta que lo pulse, y no se recuerda entre visitas. El módulo 06 explica cómo hacer un modo oscuro sin parpadeo.

10.3. Datos del servidor en un contexto

Un proveedor puede recibir datos del servidor como cualquier otro Client Component, siempre que sean serializables. Es la forma de que muchos Client Components compartan, por ejemplo, el usuario conectado sin pasarlo de mano en mano. Lo harás en el ejercicio 7.

10.4. ¿Y para compartir datos entre Server Components?

Entre Server Components no hace falta contexto: cada uno puede pedir lo que necesite. Para que pedir lo mismo varias veces no cueste varias peticiones, Next deduplica las llamadas a fetch idénticas durante un render, y para cualquier otra función tienes cache de React. Ambas cosas se explican en el módulo 08.


11. server-only y client-only

11.1. Envenenar el entorno

Un módulo como lib/api.ts puede importarse, técnicamente, desde los dos grafos. Si un día alguien lo importa desde un Client Component, pueden pasar dos cosas malas:

  • Si usa una variable de entorno secreta (sin NEXT_PUBLIC_), Next la sustituye por una cadena vacía en el paquete del cliente: el código no fallará al compilar, sino que hará peticiones sin token, y el error será difícil de encontrar.
  • Si usa algo que sí existe en el navegador, como fetch, la lógica del servidor (URLs internas, nombres de tablas, reglas de negocio) acabará en el JavaScript público.

La importación import 'server-only' convierte ese descuido en un error de compilación. Este es el resultado real de importar lib/api.ts desde un Client Component:

// app/errores/server-only/cliente.tsx
'use client'

import { obtenerEmpleados } from '@/lib/api'

export function Cliente() {
  return <button onClick={() => obtenerEmpleados()}>Cargar</button>
}
⨯ ./lib/api.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import 'server-only'
    | ^^^^^^^^^^^^^^^^^^^^
  2 | import type { Departamento, Empleado, Pagina } from './tipos'
It should only be used from a Server Component.
Import traces:
  Client Component Browser:
    ./lib/api.ts [Client Component Browser]
    ./app/errores/server-only/cliente.tsx [Client Component Browser]
    ./app/errores/server-only/cliente.tsx [Server Component]
    ./app/errores/server-only/page.tsx [Server Component]
  Client Component SSR:
    ./lib/api.ts [Client Component SSR]
    ./app/errores/server-only/cliente.tsx [Client Component SSR]
    ...

La traza de importación es muy valiosa: te dice exactamente qué cadena de import ha arrastrado el módulo al cliente, y muestra que Next compila cliente.tsx en tres “capas”: como referencia en el grafo del servidor ([Server Component]), para el SSR ([Client Component SSR]) y para el navegador ([Client Component Browser]). Es la confirmación de los dos grafos del apartado 2.

📘 Concepto: Además de esta línea, Next muestra antes un primer mensaje que menciona el Pages Router (“but you are using it in the Pages Router”). Es un texto genérico de Turbopack y aquí no tiene sentido: no hay Pages Router. Fíjate en el segundo mensaje y en la traza.

11.2. El simétrico: client-only

import 'client-only' marca un módulo que solo tiene sentido en el navegador, por ejemplo porque usa window o localStorage:

// lib/navegador.ts
import 'client-only'

export function leerPreferencia(clave: string) {
  return window.localStorage.getItem(clave)
}

Si un Server Component lo importa:

⨯ ./lib/navegador.ts:1:1
Error: 'client-only' cannot be imported from a Server Component module
> 1 | import 'client-only'
    | ^^^^^^^^^^^^^^^^^^^^
It should only be used from a Client Component.
Import trace:
  Server Component:
    ./lib/navegador.ts
    ./app/errores/client-only/page.tsx

Úsalo menos que server-only: un módulo con window importado desde el servidor ya falla solo en cuanto se ejecuta. Su valor está en dar un error claro y al compilar, en vez de un window is not defined en producción.

11.3. ¿Hay que instalarlos?

Según la documentación de Next, instalar los paquetes server-only y client-only es opcional: Next reconoce esas importaciones y las trata internamente, sin usar el contenido de los paquetes de npm. Lo comprobamos en el laboratorio de Next.js 15, que no tiene server-only en node_modules: el proyecto con import 'server-only' compiló sin problemas. Instálalos (npm install server-only) si tu configuración de ESLint marca las dependencias no declaradas.

11.4. Variables de entorno y la frontera

Las variables de entorno siguen la misma lógica que los módulos:

Variable En un Server Component En un Client Component
API_URL (sin prefijo) Su valor real Cadena vacía (Next la sustituye al compilar)
NEXT_PUBLIC_API_URL Su valor real Su valor, incrustado en el JS público

Un secreto nunca lleva NEXT_PUBLIC_. Lo verás a fondo en el módulo 13.


12. Bibliotecas de terceros

12.1. Paquetes sin 'use client'

Muchas bibliotecas de componentes de React se escribieron antes de los Server Components. Usan useState o useEffect, pero no declaran 'use client'. Si importas uno de sus componentes desde un Server Component, Next lo trata como de servidor y falla. Lo simulamos con un “paquete” local:

// bibliotecas/acme-carrusel/index.tsx
// Simula un paquete de npm antiguo: usa estado, pero NO lleva 'use client'
import { useState } from 'react'

export function Carrusel({ imagenes }: { imagenes: string[] }) {
  const [indice, setIndice] = useState(0)
  return (
    <div className="panel">
      <p>
        Imagen {indice + 1} de {imagenes.length}: {imagenes[indice]}
      </p>
      <button type="button" onClick={() => setIndice((i) => (i + 1) % imagenes.length)}>
        Siguiente
      </button>
    </div>
  )
}
// app/errores/carrusel/page.tsx
import { Carrusel } from '@/bibliotecas/acme-carrusel'

export default function Pagina() {
  return <Carrusel imagenes={['a.jpg', 'b.jpg']} />
}
⨯ ./bibliotecas/acme-carrusel/index.tsx:2:10
Error: You're importing a module that depends on `useState` into a React Server
Component module. This API is only available in Client Components. To fix, mark
the file (or its parent) with the `"use client"` directive.
Import trace:
  Server Component:
    ./bibliotecas/acme-carrusel/index.tsx
    ./app/errores/carrusel/page.tsx

No puedes editar un paquete de node_modules, pero sí puedes crear un envoltorio: un fichero tuyo con 'use client' que reexporta el componente.

// app/_componentes/carrusel.tsx
'use client'

// Envoltorio: marca la frontera para un paquete que no la marca
export { Carrusel } from '@/bibliotecas/acme-carrusel'
// app/terceros/page.tsx (Server Component)
import { Carrusel } from '../_componentes/carrusel'

// ...
<Carrusel imagenes={['oficina-madrid.jpg', 'almacen-sevilla.jpg', 'tienda-bilbao.jpg']} />

El envoltorio es el punto de entrada; el paquete queda dentro del grafo del cliente por importación. Ya no hace falta nada más. Si el componente ya se usaba dentro de otro Client Component tuyo, ni siquiera necesitas el envoltorio: lo que importa un Client Component ya es cliente.

12.2. La trampa de los componentes compuestos

Algunas bibliotecas exponen subcomponentes como propiedades estáticas: Menu.Item, Tabs.Panel, Card.Header. Este patrón se rompe al cruzar la frontera. Cuando un Server Component importa un Client Component, no recibe la función del componente, sino una referencia (la I[...] del payload), y esa referencia no tiene la propiedad Item.

// app/_componentes/menu.tsx
'use client'

import { createContext, use, useState, type ReactNode } from 'react'

const ContextoMenu = createContext<{ abierto: boolean } | null>(null)

export function Menu({ titulo, children }: { titulo: string; children: ReactNode }) {
  const [abierto, setAbierto] = useState(false)
  return (
    <ContextoMenu value={{ abierto }}>
      <button type="button" onClick={() => setAbierto(!abierto)}>
        {titulo} {abierto ? '▲' : '▼'}
      </button>
      {abierto && <ul className="panel">{children}</ul>}
    </ContextoMenu>
  )
}

// Exportación con nombre: se puede usar desde un Server Component
export function MenuItem({ children }: { children: ReactNode }) {
  const contexto = use(ContextoMenu)
  if (!contexto) throw new Error('MenuItem debe ir dentro de <Menu>')
  return <li>{children}</li>
}

// Propiedad estática: solo funciona desde otro Client Component
Menu.Item = MenuItem
// app/errores/compuesto/page.tsx  (MAL, Server Component)
import { Menu } from '../../_componentes/menu'

export default function Pagina() {
  return (
    <Menu titulo="Acciones">
      <Menu.Item>Exportar</Menu.Item>
    </Menu>
  )
}

Lo traicionero es que la página carga sin errores: el menú está cerrado y sus children no se renderizan. El fallo aparece al pulsar el botón, en el navegador, y se lleva por delante la página entera:

Element type is invalid: expected a string (for built-in components) or a
class/function (for composite components) but got: undefined. You likely forgot
to export your component from the file it's defined in, or you might have mixed
up default and named imports.
Check the render method of `Pagina`.

Menu.Item era undefined en el servidor, y ese undefined viajó en el payload como tipo de elemento. La solución es usar exportaciones con nombre:

// app/terceros/page.tsx
import { Menu, MenuItem } from '../_componentes/menu'

<Menu titulo="Acciones">
  <MenuItem>Exportar empleados</MenuItem>
  <MenuItem>Imprimir listado</MenuItem>
</Menu>

Si la biblioteca solo ofrece la forma Menu.Item, úsala dentro de un Client Component tuyo (que la importa entera, sin cruzar la frontera) y renderiza ese componente desde el servidor.

12.3. Si escribes una biblioteca

Si un día publicas componentes, la documentación de Next aconseja poner 'use client' en los puntos de entrada que usan funciones de cliente, para que quien los use pueda importarlos desde Server Components sin envoltorios. Ojo: algunos empaquetadores eliminan las directivas al generar el paquete, y hay que configurarlos para que las conserven.


13. Método: cómo repartir una pantalla nueva

Con todo lo anterior, puedes seguir siempre los mismos pasos. Vamos a aplicarlos a una pantalla que harás más adelante en el curso: el detalle de un pedido en la zona de gestión, con sus líneas, un selector para cambiar el estado del pedido y un botón para imprimir.

Paso 1. Dibuja el árbol y marca lo interactivo. Escribe los bloques de la pantalla y, al lado de cada uno, si necesita estado, eventos o APIs del navegador:

PaginaPedido
├─ CabeceraPedido        (cliente, fecha, total)       → nada interactivo
├─ TablaLineas           (producto, cantidad, precio)  → nada interactivo
├─ SelectorEstado        (select + guardar)            → estado y evento
└─ BotonImprimir         (window.print())              → evento y API del navegador

Paso 2. Las hojas interactivas son los puntos de entrada. SelectorEstado y BotonImprimir llevan 'use client'. Todo lo demás se queda en el servidor, incluida la página.

Paso 3. Decide qué datos cruza cada frontera. BotonImprimir no necesita nada. SelectorEstado necesita el id del pedido, el estado actual y la lista de estados posibles: tres valores simples, no el pedido entero con las líneas y los datos del cliente.

Paso 4. Busca contenedores interactivos. Si el diseño pide que las líneas se puedan plegar, no conviertas TablaLineas en cliente: crea un Desplegable de cliente y pásale la tabla como children.

Paso 5. Pon los huecos de carga. Cada bloque que lee datos va dentro de un <Suspense> con un fallback razonable, para que el armazón se pueda prerenderizar y la página se muestre por partes.

Paso 6. Protege la capa de datos. Las funciones que leen la API o la base de datos viven en lib/ con import 'server-only'.

Paso 7. Comprueba lo que llega. Tras el build, busca en el HTML de la página algún campo que no debería verse (un email, un coste interno). Si aparece, alguna prop está pasando de más.

💡 Consejo: Si dudas entre dos opciones, empieza por la de servidor. Pasar un componente de servidor a cliente es añadir una línea; el camino contrario suele obligar a reescribir cómo llegan los datos.

13.1. Herramientas para depurar la frontera

Pregunta Cómo responderla
¿Este componente se ejecuta en el navegador? console.log en su cuerpo: si sale en la consola del navegador, es cliente
¿Por qué este módulo ha acabado en el cliente? Añade import 'server-only' temporalmente y lee la traza de importación del error
¿Qué datos recibe este Client Component? “Ver código fuente” de la página y busca su nombre: verás la línea I[...] y, justo después, sus props
¿Mi código de servidor está en el JS público? Tras next build, busca una cadena única de ese código en .next/static
¿Por qué esta página no se prerenderiza? Lee el aviso de next dev en la terminal: dice qué acceso a datos está fuera de <Suspense> y en qué línea

14. Errores frecuentes

Todos los mensajes de esta tabla son los reales de Next.js 16.4 (en el build o en next dev, y en el último caso, en la consola del navegador).

Mensaje (resumido) Causa Solución
“You’re importing a module that depends on useState into a React Server Component module” Un hook de estado o efecto en un fichero sin 'use client' Mover esa parte a un Client Component
“…depends on createContext…” Crear un contexto en un Server Component Proveedor en un fichero 'use client' con children
“Event handlers cannot be passed to Client Component props” onClick, onChange… en el JSX de un Server Component Extraer el elemento interactivo a un Client Component
“Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with “use server”” Pasar una función normal como prop desde el servidor Crear la función en el cliente, o usar una Server Action
“Only plain objects, and a few built-ins, can be passed… Classes or null prototypes are not supported” Pasar una instancia de clase Convertirla en un objeto plano con los campos necesarios
“Only global symbols received from Symbol.for(…)…” Pasar un Symbol() local Symbol.for('...') o, mejor, una cadena
“URL objects are not supported” Pasar un URL Pasar url.href
“The “use client” directive must be placed before other expressions” Directiva después de un import Moverla a la primera línea
“%s is an async Client Component. Only Server Components can be async” async en un componente de un fichero 'use client' Quitar la directiva, o cargar en el servidor y pasar datos o una promesa
“‘server-only’ cannot be imported from a Client Component module” Un Client Component importa (directa o indirectamente) un módulo de servidor Seguir la traza de importación y cortar la cadena
“‘client-only’ cannot be imported from a Server Component module” Un Server Component importa un módulo solo de navegador Usarlo desde un Client Component
“Element type is invalid… got: undefined” al interactuar Menu.Item usado desde un Server Component Exportaciones con nombre (MenuItem)
“Next.js encountered uncached data during prerendering” await de datos sin caché fuera de <Suspense> con cacheComponents Envolver en <Suspense> o cachear con 'use cache'
“Hydration failed because the server rendered text didn’t match the client” El render del servidor y el del navegador no coinciden Ver el módulo 23 del Curso de React; no inspecciones el JSX recibido como prop

💡 Consejo: Cuando no sepas si un componente es de servidor o de cliente, pon un console.log('render X') en él y recarga la página. Si solo sale en la terminal, es de servidor. Si sale también en la consola del navegador, es de cliente.


15. Ejercicios

Ejercicio 1: Clasificar componentes

Para cada componente de la zona de gestión de EMPRESA, decide si debe ser Server o Client Component, y por qué:

  1. La cabecera con el logo y los enlaces de navegación.
  2. Un campo de búsqueda que filtra la tabla de productos mientras escribes.
  3. La ficha de un pedido con sus líneas, leída de la API.
  4. Un botón “Copiar al portapapeles”.
  5. Un gráfico de barras hecho con una biblioteca que usa useEffect para dibujar en un <canvas>.
  6. Una sección “Preguntas frecuentes” que se abre y se cierra.
  7. Un aviso de “Tienes cambios sin guardar” que aparece al salir de la página.
Ver solución
Componente Tipo Motivo
1. Cabecera Servidor Contenido estático y <Link>, que ya es un Client Component hecho. Si tiene un botón de tema, solo ese botón es cliente
2. Búsqueda en vivo Cliente Estado que cambia con cada tecla. La lista puede cargarse en el servidor y pasarse como prop
3. Ficha del pedido Servidor Solo lee datos y los muestra; puede ser async y hacer fetch
4. Copiar Cliente Usa navigator.clipboard y un manejador onClick
5. Gráfico Cliente Usa useEffect y el <canvas> del navegador. Si la biblioteca no declara 'use client', envoltorio
6. Preguntas frecuentes Servidor <details> y <summary> lo hacen sin JavaScript
7. Aviso al salir Cliente Necesita el evento beforeunload del navegador

Fíjate en que el criterio nunca es “¿es importante?” ni “¿es grande?”, sino “¿necesita estado, eventos o APIs del navegador?”.

Ejercicio 2: El botón de eliminar

Un compañero ha escrito esta página y da el error “Event handlers cannot be passed to Client Component props”. Corrígela para que la lista siga siendo un Server Component y el botón pida confirmación con window.confirm y quede deshabilitado tras confirmar (el borrado real lo harás con Server Actions en el módulo 10).

export default function Pagina() {
  const departamentos = [
    { deptId: 60, nombre: 'LOGÍSTICA' },
    { deptId: 70, nombre: 'INVESTIGACIÓN' },
  ]
  return (
    <ul>
      {departamentos.map((d) => (
        <li key={d.deptId}>
          {d.nombre}{' '}
          <button onClick={() => confirm(`¿Eliminar ${d.nombre}?`)}>Eliminar</button>
        </li>
      ))}
    </ul>
  )
}
Ver solución

Se extrae el botón a su propio fichero 'use client'. El servidor solo le pasa datos (el id y el nombre); la función del manejador nace en el cliente.

// app/ejercicios/eliminar/boton-eliminar.tsx
'use client'

import { useState } from 'react'

export function BotonEliminar({ deptId, nombre }: { deptId: number; nombre: string }) {
  const [marcado, setMarcado] = useState(false)

  function alPulsar() {
    if (window.confirm(`¿Eliminar ${nombre}?`)) {
      // En el módulo 10 esto llamará a una Server Action
      console.log('Eliminar departamento', deptId)
      setMarcado(true)
    }
  }

  return (
    <button type="button" onClick={alPulsar} disabled={marcado}>
      {marcado ? 'Marcado para borrar' : 'Eliminar'}
    </button>
  )
}
// app/ejercicios/eliminar/page.tsx
import { BotonEliminar } from './boton-eliminar'

const departamentos = [
  { deptId: 60, nombre: 'LOGÍSTICA' },
  { deptId: 70, nombre: 'INVESTIGACIÓN' },
]

export default function Pagina() {
  return (
    <ul>
      {departamentos.map((d) => (
        <li key={d.deptId}>
          {d.nombre} <BotonEliminar deptId={d.deptId} nombre={d.nombre} />
        </li>
      ))}
    </ul>
  )
}

La página compila como estática (○) y muestra “LOGÍSTICA Eliminar” e “INVESTIGACIÓN Eliminar”. window.confirm se llama dentro del manejador, nunca durante el render, así que no hay problema con que el componente también se ejecute en el servidor.

Ejercicio 3: Ficha de empleado con datos mínimos

Haz una página que muestre la ficha del empleado 100 (GET /api/empleados/100): nombre, apellidos y salario pintados por el servidor, y un Client Component TarjetaContacto con el email, el teléfono y un botón “Copiar email”. El objeto completo del empleado no debe llegar al navegador. Comprueba en el HTML que la palabra salario no aparece.

Ver solución

Añadimos una función a la capa de datos (protegida con server-only):

// lib/api-extra.ts (fragmento)
import 'server-only'
import type { Empleado } from './tipos'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

export async function obtenerEmpleado(id: number): Promise<Empleado> {
  const respuesta = await fetch(`${API}/empleados/${id}`)
  if (!respuesta.ok) throw new Error(`Empleado ${id} no encontrado`)
  return respuesta.json()
}
// app/ejercicios/ficha/page.tsx
import { Suspense } from 'react'
import { obtenerEmpleado } from '@/lib/api-extra'
import { TarjetaContacto } from './tarjeta-contacto'

async function Ficha({ id }: { id: number }) {
  const empleado = await obtenerEmpleado(id)

  return (
    <article>
      <h2>
        {empleado.nombre} {empleado.apellidos}
      </h2>
      {/* El salario se pinta en el servidor: llega como texto, no como dato suelto */}
      <p>Salario bruto mensual: {empleado.salario} €</p>
      <TarjetaContacto email={empleado.email} telefono={empleado.telefono} />
    </article>
  )
}

export default function Pagina() {
  return (
    <Suspense fallback={<p>Cargando ficha...</p>}>
      <Ficha id={100} />
    </Suspense>
  )
}
// app/ejercicios/ficha/tarjeta-contacto.tsx
'use client'

import { useState } from 'react'

export function TarjetaContacto({ email, telefono }: { email: string; telefono: string }) {
  const [copiado, setCopiado] = useState(false)

  async function copiar() {
    await navigator.clipboard.writeText(email)
    setCopiado(true)
  }

  return (
    <p>
      {email} · {telefono}{' '}
      <button type="button" onClick={copiar}>
        {copiado ? 'Copiado' : 'Copiar email'}
      </button>
    </p>
  )
}

La página muestra “Ana García López”, “Salario bruto mensual: 6500 €” y “ana.garcia@empresa.es · 600100100 Copiar email”. En el HTML descargado, salario aparece 0 veces y 6500 aparece 2: una en el HTML visible y otra en el payload, como texto del párrafo. Es lo esperado: el usuario ve su salario, pero no recibe el objeto con los demás campos (jefeId, comision, fechaAlta…). TarjetaContacto solo recibe lo que usa.

Muestra los cinco primeros productos de GET /api/productos (nombre y descripción) en un Server Component, con un botón de estrella para marcar favorito junto a cada uno. La estrella empieza marcada si el producto tiene más de 20 unidades en stock (es solo para practicar el valor inicial). Solo el botón debe ser cliente.

Ver solución
// lib/api-extra.ts (fragmento)
export type Producto = {
  productoId: number
  nombre: string
  descripcion: string
  categoriaId: number
  precio: number
  stock: number
  fechaAlta: string
  descatalogado: boolean
}

export async function obtenerProductos(): Promise<Producto[]> {
  const respuesta = await fetch(`${API}/productos?tamano=100`)
  if (!respuesta.ok) throw new Error('No se pudieron cargar los productos')
  const pagina: Pagina<Producto> = await respuesta.json()
  return pagina.contenido
}
// app/ejercicios/favoritos/boton-favorito.tsx
'use client'

import { useState } from 'react'

export function BotonFavorito({ productoId, inicial }: { productoId: number; inicial: boolean }) {
  const [favorito, setFavorito] = useState(inicial)
  return (
    <button
      type="button"
      aria-pressed={favorito}
      aria-label={`Favorito ${productoId}`}
      onClick={() => setFavorito(!favorito)}
    >
      {favorito ? '★' : '☆'}
    </button>
  )
}
// app/ejercicios/favoritos/page.tsx
import { Suspense } from 'react'
import { obtenerProductos } from '@/lib/api-extra'
import { BotonFavorito } from './boton-favorito'

async function Catalogo() {
  const productos = (await obtenerProductos()).slice(0, 5)
  return (
    <ul>
      {productos.map((p) => (
        <li key={p.productoId}>
          <strong>{p.nombre}</strong>: {p.descripcion}{' '}
          <BotonFavorito productoId={p.productoId} inicial={p.stock > 20} />
        </li>
      ))}
    </ul>
  )
}

export default function Pagina() {
  return (
    <Suspense fallback={<p>Cargando catálogo...</p>}>
      <Catalogo />
    </Suspense>
  )
}

Salida real (las tres primeras líneas):

Portátil UltraBook 14: Portátil ligero de 14 pulgadas, 16 GB RAM ★
Portátil Gaming 16: Portátil de 16 pulgadas con gráfica dedicada ☆
Portátil Básico 15: Portátil de 15 pulgadas para ofimática ★

La prop inicial solo se usa como valor inicial de useState. Si el servidor vuelve a renderizar con otro valor, el estado no se reinicia: es el comportamiento normal de useState, no algo de Next. El stock tampoco viaja: el servidor calcula p.stock > 20 y pasa un booleano.

Ejercicio 5: Desplegable con contenido de servidor

Crea un Client Component Desplegable con un botón que muestra u oculta sus children. Úsalo desde un Server Component para mostrar la tabla de departamentos cargada de la API. Después, haz lo mismo sin ningún Client Component. ¿Qué diferencia hay en lo que llega al navegador?

Ver solución

El Desplegable es el del apartado 8.4. La página:

// app/ejercicios/desplegable/page.tsx
import { Suspense } from 'react'
import { obtenerDepartamentos } from '@/lib/api'
import { Desplegable } from './desplegable'

async function TablaDepartamentos() {
  const departamentos = await obtenerDepartamentos()
  return (
    <table>
      <tbody>
        {departamentos.map((d) => (
          <tr key={d.deptId}>
            <td>{d.nombre}</td>
            <td>{d.localidad}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

export default function Pagina() {
  return (
    <section>
      <Desplegable titulo="Departamentos (Client Component)">
        <Suspense fallback={<p>Cargando...</p>}>
          <TablaDepartamentos />
        </Suspense>
      </Desplegable>

      {/* Variante sin JavaScript: el navegador ya sabe abrir y cerrar */}
      <details>
        <summary>Departamentos (solo HTML)</summary>
        <Suspense fallback={<p>Cargando...</p>}>
          <TablaDepartamentos />
        </Suspense>
      </details>
    </section>
  )
}

Diferencias:

  • Las dos versiones renderizan la tabla en el servidor, aunque esté cerrada. En el HTML descargado, “INVESTIGACIÓN” aparece 3 veces: en el HTML de <details> (oculto, pero presente) y en el payload de cada uno de los dos desplegables. El de cliente no tiene la tabla en su HTML inicial porque {abierto && children} no la pinta, pero sí la tiene en el payload, lista para mostrarse sin petición.
  • La versión <details> no envía JavaScript propio y funciona antes de la hidratación, e incluso con JavaScript desactivado.
  • La versión de cliente permite cosas que <details> no: animaciones controladas, cerrar los demás al abrir uno, guardar el estado…

Si no necesitas nada de lo último, elige <details>.

Ejercicio 6: Un módulo con secreto

Imagina que la API EMPRESA exige un token de servicio en todas las peticiones. Crea lib/config-secreta.ts con una función cabecerasApi() que lea EMPRESA_API_TOKEN del entorno y devuelva la cabecera Authorization. Garantiza que el build falle si alguien la importa desde un Client Component, y úsala en obtenerEmpleado.

Ver solución
// lib/config-secreta.ts
import 'server-only'

// Sin prefijo NEXT_PUBLIC_: nunca debe llegar al navegador
const TOKEN = process.env.EMPRESA_API_TOKEN ?? ''

export function cabecerasApi(): HeadersInit {
  return TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}
}
// lib/api-extra.ts (fragmento)
import 'server-only'
import type { Empleado, Pagina } from './tipos'
import { cabecerasApi } from './config-secreta'

export async function obtenerEmpleado(id: number): Promise<Empleado> {
  const respuesta = await fetch(`${API}/empleados/${id}`, { headers: cabecerasApi() })
  if (!respuesta.ok) throw new Error(`Empleado ${id} no encontrado`)
  return respuesta.json()
}

Dos capas de protección:

  1. Sin NEXT_PUBLIC_: aunque el módulo llegase al cliente, process.env.EMPRESA_API_TOKEN valdría una cadena vacía.
  2. import 'server-only': el módulo no puede llegar al cliente. Si un Client Component lo importa, aunque sea a través de tres ficheros intermedios, el build falla con “‘server-only’ cannot be imported from a Client Component module” y la traza muestra la cadena completa.

La segunda capa es la importante: la primera evita que se filtre el secreto, pero sin la segunda tendrías un error silencioso (peticiones sin token) en lugar de un error de compilación.

Ejercicio 7: El usuario conectado en un contexto

En la zona /ejercicios/usuario, el layout debe obtener el “usuario conectado” en el servidor (de momento, simúlalo con el empleado 101) y ponerlo a disposición de cualquier Client Component de debajo con un hook useUsuario(). El usuario tiene nombre (nombre y apellidos) y rol ('ADMIN' si es el director general, 'USER' en otro caso). La página muestra un saludo hecho con un Client Component.

Ver solución
// app/ejercicios/usuario/proveedor-usuario.tsx
'use client'

import { createContext, use, type ReactNode } from 'react'

export type Usuario = { nombre: string; rol: 'ADMIN' | 'USER' }

const ContextoUsuario = createContext<Usuario | null>(null)

export function ProveedorUsuario({ usuario, children }: { usuario: Usuario; children: ReactNode }) {
  return <ContextoUsuario value={usuario}>{children}</ContextoUsuario>
}

export function useUsuario() {
  const usuario = use(ContextoUsuario)
  if (!usuario) throw new Error('useUsuario necesita <ProveedorUsuario>')
  return usuario
}
// app/ejercicios/usuario/layout.tsx
import { Suspense } from 'react'
import { obtenerEmpleado } from '@/lib/api-extra'
import { ProveedorUsuario } from './proveedor-usuario'

async function ConUsuario({ children }: { children: React.ReactNode }) {
  // En el módulo 17 este dato saldrá de la sesión; aquí lo simulamos
  const empleado = await obtenerEmpleado(101)
  const usuario = {
    nombre: `${empleado.nombre} ${empleado.apellidos}`,
    rol: empleado.oficio === 'DIRECTOR GENERAL' ? 'ADMIN' : 'USER',
  } as const

  return <ProveedorUsuario usuario={usuario}>{children}</ProveedorUsuario>
}

export default function LayoutUsuario({ children }: LayoutProps<'/ejercicios/usuario'>) {
  return (
    <Suspense fallback={<p>Cargando usuario...</p>}>
      <ConUsuario>{children}</ConUsuario>
    </Suspense>
  )
}
// app/ejercicios/usuario/saludo-usuario.tsx
'use client'

import { useUsuario } from './proveedor-usuario'

export function SaludoUsuario() {
  const { nombre, rol } = useUsuario()
  return (
    <p>
      Hola, {nombre}. Tu rol es {rol}.
    </p>
  )
}
// app/ejercicios/usuario/page.tsx
import { SaludoUsuario } from './saludo-usuario'

export default function Pagina() {
  return (
    <section>
      <h1>Mi zona</h1>
      <SaludoUsuario />
    </section>
  )
}

Resultado real: “Hola, Luis Martínez Ruiz. Tu rol es USER.”

Detalles a observar:

  • El proveedor está en el layout de la zona, no en el raíz: “lo más abajo posible”.
  • Al contexto llega un objeto construido a propósito con dos campos, no el empleado entero.
  • as const hace que TypeScript deduzca 'ADMIN' | 'USER' en lugar de string, para que encaje con el tipo Usuario.
  • El rol del contexto sirve para mostrar u ocultar cosas en la interfaz, nunca para autorizar. La autorización real se hace en el servidor (módulo 17).

Ejercicio 8: Catálogo con promesa y filtro por precio

Crea /ejercicios/catalogo: la página (Server Component, sin async) inicia la carga de productos y pasa al cliente una promesa con solo productoId, nombre y precio. Un Client Component la lee con use() y muestra un control deslizante de precio máximo (0 a 1000 €, de 50 en 50) que filtra la lista.

Ver solución
// app/ejercicios/catalogo/page.tsx
import { Suspense } from 'react'
import { obtenerProductos } from '@/lib/api-extra'
import { ListaProductos } from './lista-productos'

export default function Pagina() {
  const productos = obtenerProductos().then((lista) =>
    lista.map((p) => ({ productoId: p.productoId, nombre: p.nombre, precio: p.precio })),
  )

  return (
    <section>
      <h1>Catálogo</h1>
      <Suspense fallback={<p>Cargando productos...</p>}>
        <ListaProductos productos={productos} />
      </Suspense>
    </section>
  )
}
// app/ejercicios/catalogo/lista-productos.tsx
'use client'

import { use, useState } from 'react'

type ProductoResumen = { productoId: number; nombre: string; precio: number }

export function ListaProductos({ productos }: { productos: Promise<ProductoResumen[]> }) {
  const lista = use(productos)
  const [maximo, setMaximo] = useState(1000)
  const visibles = lista.filter((p) => p.precio <= maximo)

  return (
    <div>
      <label>
        Precio máximo: {maximo} €{' '}
        <input
          type="range"
          min={0}
          max={1000}
          step={50}
          value={maximo}
          onChange={(e) => setMaximo(Number(e.target.value))}
        />
      </label>
      <ul>
        {visibles.map((p) => (
          <li key={p.productoId}>
            {p.nombre}: {p.precio} €
          </li>
        ))}
      </ul>
    </div>
  )
}

Salida real (comienzo):

Catálogo
Precio máximo: 1000 €
Portátil UltraBook 14: 899 €
Portátil Básico 15: 499 €
Torre Oficina i5: 649 €
Ratón inalámbrico: 19.9 €
...

La clave está en el .then(...): el recorte de campos se hace en el servidor, encadenado a la promesa. Lo que se serializa es el resultado de esa promesa, así que la descripción, el stock y la fecha de alta nunca llegan. En el HTML descargado, descripcion aparece 0 veces. Los precios con decimales salen con punto (19.9 €); darles formato es parte del ejercicio 10.

Ejercicio 9 (propuesto): Arreglar un menú compuesto

Tienes una biblioteca de menús que solo ofrece la forma Menu.Item y no puedes cambiarla. Una página (Server Component) necesita mostrar un menú con tres acciones cuyos textos vienen de la API (por ejemplo, los nombres de los tres primeros departamentos). Escribe un Client Component intermedio MenuDepartamentos que reciba los textos como prop (un array de cadenas) y use Menu.Item por dentro. Comprueba con Playwright, o a mano, que al abrir el menú no aparece “Element type is invalid”.

Ejercicio 10 (propuesto): Medir el coste de la frontera

Copia el proyecto del módulo y haz un experimento:

  1. Ejecuta next build y anota el tamaño total de .next/static/chunks/*.js.
  2. Pon 'use client' en una página que hoy sea de servidor y no use datos (por ejemplo, app/ejercicios/eliminar/page.tsx), vuelve a compilar y compara el tamaño. ¿Ha cambiado mucho? ¿Por qué?
  3. Ahora haz lo mismo con una página que importe una biblioteca grande solo para el servidor (por ejemplo, una que formatee Markdown). Mide de nuevo.
  4. Añade además a los precios del ejercicio 8 un formato en euros con Intl.NumberFormat, primero en el cliente y luego precalculado en el servidor (pasando cadenas). ¿Qué opción envía menos JavaScript? ¿Cuál permite cambiar el formato sin volver al servidor?

Explica en un párrafo por qué el tamaño depende más de lo que importan los Client Components que del número de componentes.


Resumen

Concepto API Idea clave
Server Component Por defecto en el App Router Se ejecuta solo en el servidor, puede ser async y su código no llega al navegador
Client Component 'use client' Se ejecuta en el servidor (HTML) y en el navegador (hidratación); tiene estado y eventos
Dos grafos Importaciones El código cruza por import; un fichero importado por un Client Component es cliente
Punto de entrada 'use client' en la primera línea Solo en los ficheros que importa un Server Component; antes de cualquier import
RSC Payload self.__next_f.push(...) Resultado de los Server Components + referencias I[...] + props serializadas
Props serializables Formato de React Primitivos (con NaN, -0, bigint), Date, Map, Set, arrays tipados, FormData, promesas, JSX, Server Functions
No serializables   Funciones normales, instancias de clase, Symbol() local, URL
Datos públicos Tipos recortados (Pick) Todo lo que pasas como prop lo puede leer cualquiera
Composición children, props con ReactNode Un Client Component recibe Server Components ya renderizados, nunca los importa
Propietario y padre   Dónde se ejecuta un componente lo decide quién escribe su JSX
Frontera en las hojas   Solo la parte interactiva es cliente: menos JS y más acceso a datos
Promesas al cliente use(promesa) + <Suspense> El servidor empieza la petición; el cliente la lee cuando llega
<Suspense> con datos 🆕 16.0 cacheComponents Sin él, los datos sin caché rompen el build; con él la ruta es ◐
Estado al navegar 🔄 16.0 <Activity> Con Cache Components, volver a una página conserva el estado de sus Client Components
Contexto Proveedor 'use client' con children Los Server Components no leen contexto; el proveedor, lo más abajo posible
Módulos de servidor import 'server-only' Error de compilación si llega al cliente, con traza de importación
Módulos de navegador import 'client-only' Error de compilación si lo importa un Server Component
Bibliotecas sin directiva Envoltorio 'use client' que reexporta Convierte el paquete en cliente por importación
Componentes compuestos Exportaciones con nombre Menu.Item es undefined al otro lado de la frontera