Módulo 23: Server Components y Next.js 16
Objetivos del módulo
- Distinguir CSR, SSR, SSG, ISR y prerenderizado parcial, y saber qué paga cada uno
- Entender la hidratación, por qué falla y cómo se arregla un hydration mismatch
- Explicar qué es un Server Component y por qué no es lo mismo que SSR
- Leer un payload RSC real y seguir el recorrido de una página desde el servidor hasta el navegador
- Crear un proyecto con Next.js 16 y saber qué copia de React está usando de verdad
- Organizar una aplicación con el App Router:
layout,page,loading,error,not-foundy rutas dinámicas - Colocar la frontera
'use client'y componer Server y Client Components - Compartir datos con Context desde el servidor sabiendo qué se puede hacer y qué no
- Escribir Server Functions con
'use server', validarlas y autorizarlas como lo que son: puntos de entrada públicos - Enviar la página por partes con streaming y
<Suspense> - Dominar el modelo de caché actual (
cacheComponents,use cache,cacheLife,cacheTag) y reconocer el anterior - Proteger rutas con
proxy, el sucesor demiddleware, sin confiarle la seguridad - Sacar las lecciones de las vulnerabilidades RSC de diciembre de 2025
💡 Consejo: Este es el único módulo del curso que no usa Vite. Server Components necesita un servidor y un bundler preparados para ello, así que aquí trabajarás con Next.js 16.4.3. Todo lo que ves se ha compilado y ejecutado con esa versión. En Next hay muchas diferencias entre versiones, sobre todo en la caché: si lees un tutorial de Next 13, 14 o 15, gran parte no te servirá tal cual.
🆕 Desde React 19.0. Server Components, Server Functions y las directivas
'use client'y'use server'son estables desde esta versión. No existen como función estable en React 18.3 ni anteriores. Si usas una versión anterior: no hay alternativa directa con Vite y una SPA. Puedes hacer SSR clásico conrenderToPipeableStream, o usar el Pages Router de Next congetServerSideProps, pero pierdes los Server Components. Con el App Router la versión de tupackage.jsonimporta menos de lo que parece, como verás en el apartado 4.
1. Cinco formas de llevar una página al navegador
Hasta ahora, todas tus aplicaciones hacían lo mismo: el servidor entregaba un HTML casi vacío y un fichero JavaScript, y era el navegador quien construía la página. Eso tiene nombre, CSR, y es solo una de las opciones. La pregunta que las distingue es sencilla: ¿cuándo y dónde se genera el HTML?
Figura 1. Las cinco estrategias. Lo que cambia es en qué columna se hace el trabajo de generar el HTML.
| Sigla | Nombre | Cuándo se genera el HTML | Ventaja | Precio |
|---|---|---|---|---|
| CSR | Client-Side Rendering | En el navegador | Hosting estático y barato, navegación fluida | Pantalla vacía al inicio, mucho JavaScript, peor para buscadores |
| SSR | Server-Side Rendering | En el servidor, en cada petición | Contenido visible pronto y siempre actual | Necesitas un servidor, y cada petición le cuesta CPU |
| SSG | Static Site Generation | Al compilar | Rapidísimo, se sirve desde una CDN | Los datos envejecen hasta la próxima compilación |
| ISR | Incremental Static Regeneration | Al compilar, y de nuevo cuando caduca | SSG sin recompilar todo | Algunos usuarios ven datos un poco viejos |
| PPR | Partial Prerendering | Una parte al compilar, otra en cada petición | Armazón inmediato y datos actuales en la misma página | Más piezas que entender y un framework que lo soporte |
Una idea clave: estas estrategias no son exclusivas. Una misma aplicación puede tener una portada estática, un panel dinámico y un catálogo que se regenera cada hora. Y con PPR esa mezcla ocurre dentro de una sola página.
📘 Concepto: Un prerender es un render hecho antes de que llegue la petición. El resultado se guarda y se sirve a todo el mundo. Por eso no puede depender de nada que sea propio de una persona concreta, como sus cookies.
2. La hidratación
SSR y SSG entregan HTML, pero el HTML no tiene manejadores de eventos: un botón se ve y no hace nada. Para que funcione, React debe llegar al navegador, recorrer el árbol de componentes y enganchar los eventos al DOM que ya existe. Ese paso se llama hidratación.
import { hydrateRoot } from 'react-dom/client'
import { App } from './App'
// En lugar de createRoot(...).render(...), que crearía el DOM desde cero
hydrateRoot(document.getElementById('root')!, <App />)
En Next no escribirás esa línea: el framework la hace por ti. Pero conviene saber que está ahí, por dos motivos.
Primero, porque se paga dos veces. Con SSR clásico, el servidor renderiza todos los componentes y el navegador los vuelve a ejecutar enteros para hidratar. Todo el código viaja al cliente, incluso el de un texto que nunca cambiará. Este es justo el problema que atacan los Server Components.
Segundo, porque hidratar exige que ambos renders coincidan. React espera encontrar en el DOM exactamente lo que él produciría. Si no es así, tienes un hydration mismatch. Este componente lo provoca:
'use client'
export function Hora() {
return <p>{Math.random()}</p>
}
El servidor saca un número, el navegador otro. Este es el mensaje real en desarrollo:
Hydration failed because the server rendered text didn't match the client.
As a result this tree will be regenerated on the client. This can happen if
a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each
time it's called.
- Date formatting in a user's locale which doesn't match the server locale.
React se recupera y regenera ese árbol en el cliente, así que la página sigue funcionando. Pero pierdes lo ganado con SSR y el usuario puede ver un salto. La solución es que el primer render del cliente sea igual que el del servidor, y que lo específico del navegador llegue después:
'use client'
import { useEffect, useState } from 'react'
export function Hora() {
const [hora, setHora] = useState<string | null>(null)
// Los efectos no se ejecutan en el servidor
useEffect(() => {
setHora(new Date().toLocaleTimeString('es-ES'))
}, [])
return <p>{hora ?? 'Cargando hora...'}</p>
}
⚠️ Importante:
typeof window !== 'undefined'en el render no arregla nada: es exactamente la primera causa de la lista. El servidor toma una rama y el cliente, otra. El atributosuppressHydrationWarningsolo sirve para diferencias inevitables de un único nodo de texto, como una fecha, y silencia el aviso sin corregir el contenido.
3. Qué son los Server Components 🆕 19.0
🆕 Desde React 19.0. No existen como función estable en React 18.3 ni anteriores. Si usas una versión anterior: usa SSR clásico o datos con
fetchdesde el cliente, como en el módulo 19. No tendrás componentes que se ejecuten solo en el servidor.
Un React Server Component (RSC) es un componente que se ejecuta únicamente en el servidor. Su código nunca se envía al navegador. Por eso puede hacer cosas imposibles en un componente normal:
export default async function PaginaDepartamentos() {
// Es una función async, y este await está en el propio componente
const respuesta = await fetch(`${process.env.API_URL}/departamentos`)
const { contenido } = await respuesta.json()
return (
<ul>
{contenido.map((d: { deptId: number; nombre: string }) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
Sin useEffect, sin estado de carga, sin biblioteca de datos. Y API_URL puede ser un secreto, porque esta función no sale del servidor.
No es lo mismo que SSR
Esta es la confusión más frecuente del tema, y merece la pena aclararla bien:
| SSR clásico | Server Components | |
|---|---|---|
| Qué produce | HTML | Un árbol de React serializado (payload RSC) |
| Cuándo se ejecuta | Al pedir la página | Al compilar o al pedir, según la caché |
| Código en el navegador | Sí, todo, para hidratar | No, ninguno |
Puede usar useState | Sí | No |
| ¿Se vuelve a ejecutar en el navegador? | Sí, al hidratar | Nunca |
| Al navegar por la aplicación | Solo en la primera carga | También: el servidor manda nuevo payload |
Son piezas complementarias, y Next usa las dos a la vez. Los Server Components producen el payload, y el SSR convierte ese payload, junto con los Client Components, en el HTML de la primera carga.
Dos entornos, un solo árbol
Cuando escribes 'use client' en un fichero, le dices a React que ese componente y lo que importe pertenecen al navegador. El resto, en el App Router, se ejecuta en el servidor.
Figura 2. La frontera 'use client' separa los dos entornos. Lo que pertenece a cada uno decide qué puede hacer.
El recorrido completo
Figura 3. En la primera carga llegan HTML, payload y JavaScript. En las navegaciones siguientes basta con el payload.
Observa el paso 6. Como el payload es una descripción de la interfaz y no HTML, React puede fundirlo con lo que ya hay en pantalla al navegar, conservando el estado de los Client Components. Es la gran diferencia con recargar una página.
Cómo es un payload RSC
No es teoría: puedes verlo. Pide cualquier ruta con la cabecera RSC: 1 y el servidor responderá con el payload, con tipo text/x-component:
curl -H "RSC: 1" http://localhost:3001/contexto
Este es un extracto de la respuesta real de la página de ejemplo del apartado 8:
b:I[6472,["$2","$a"],"TemaContexto"]
c:I[72446,["$2","$a"],"Etiqueta"]
9:[["children",{...,"d":{"r":["$","$1","c",{"children":[["$","$Lb",null,
{"value":"oscuro","children":["$","div",null,{"className":"tarjeta",
"children":["$","$Lc",null,{}]}]}]...
Se lee así:
- Las líneas con
I[...]son referencias a módulos cliente: “paraEtiqueta, carga este módulo”. El payload no lleva su código, solo cómo encontrarlo. ["$","div",null,{...}]es un elemento de React ya resuelto: el resultado de ejecutar el Server Component. Ahí no queda rastro de la función que lo generó.$Lby$Lcson huecos que apuntan a esas referencias perezosas.
Este formato se llama Flight dentro de React. Como cuenta el apartado 13, serializar y deserializar datos entre dos entornos no es un detalle menor: ahí se produjo la vulnerabilidad de diciembre de 2025, con la puntuación máxima de gravedad.
📘 Concepto: Los Server Components no son una función de Next. Son una función de React que necesita la colaboración del bundler, porque alguien tiene que separar el código de cada entorno. Next es el framework que más la ha desarrollado, pero también la soportan React Router, Waku,
@vitejs/plugin-rsc,@parcel/rscy RedwoodSDK (rwsdk), los mismos que cita el aviso oficial de seguridad.
4. Next.js 16: el proyecto
Crear el proyecto
npx create-next-app@latest empresa-next --yes
cd empresa-next
npm install zod
La opción --yes acepta los valores recomendados: TypeScript, ESLint, Tailwind y App Router. Con Next 16.4.3 este es el next.config.ts que obtienes, sin la parte de Tailwind:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Esas dos líneas son decisivas: los proyectos nuevos arrancan con Cache Components activado. Lo verás a fondo en el apartado 11. Si sigues un tutorial anterior, este es el primer motivo por el que su código puede no funcionar.
Compara lo que trae con lo que has usado en el curso:
| Vite del curso | create-next-app 16.4.3 | |
|---|---|---|
react en package.json | 19.3.1 | 19.3.0 |
| TypeScript | 7.0 | ^5 |
| Lint | oxlint | ESLint 9 con eslint-config-next |
| Bundler | Vite 8 | Turbopack, por defecto desde Next 16 |
Dos procesos, dos puertos
Next arranca por defecto en el puerto 3000, el mismo que la API de ejemplo. Descarga la API de ejemplo, arráncala primero y lanza Next en el 3001:
# Terminal 1, en la carpeta de la API
node server.mjs
# Terminal 2, en el proyecto de Next
npm run dev -- --port 3001
Crea un fichero .env.local en la raíz del proyecto:
API_URL=http://localhost:3000/api
⚠️ Importante:
next buildejecuta tus componentes para prerenderizarlos. Si un componente conuse cachellama a la API, la API debe estar en marcha mientras compilas. No es un capricho: los datos pasan a formar parte del resultado. En un servidor de integración continua, tenlo en cuenta.
La copia de React que usa Next de verdad
Aquí viene algo que sorprende. Next trae su propia copia de React dentro de node_modules/next/dist/compiled, y el App Router usa esa copia, no la de tu package.json. Compruébalo con una página que muestre version en los dos entornos:
// app/version/page.tsx
import { version } from 'react'
import { VersionCliente } from './VersionCliente'
export default function PaginaVersion() {
return (
<div className="tarjeta">
<h2>Versión de React</h2>
<p>Server Component: {version}</p>
<VersionCliente />
</div>
)
}
// app/version/VersionCliente.tsx
'use client'
import { version } from 'react'
export function VersionCliente() {
return <p>Client Component: {version}</p>
}

Figura 4. Con react 19.3.1 instalado en node_modules, el App Router de Next 16.4.3 usa su canary.
Medido en un proyecto con react 19.3.1 en node_modules:
| Dónde se ejecuta | version de react |
|---|---|
| Server Component, App Router | 19.3.0-canary-278794d7-20261002 |
| Client Component, App Router | 19.3.0-canary-278794d7-20261002 |
Página del Pages Router (pages/) | 19.3.1 |
El Pages Router sí usa el react de tu proyecto. El App Router no.
¿Por qué lo hace? Los Server Components dependen de detalles internos entre React y el bundler que pueden cambiar entre versiones menores. Next documenta que “el App Router usa la última versión Canary de React”, y publica cada versión con una canary concreta y probada. El rango de peerDependencies de next admite ^18.2.0 || ^19.0.0, pero eso se refiere a lo instalado, no a lo que ejecuta el App Router.
Consecuencias prácticas:
- Lo que pone tu
package.jsonno dice qué React corre en tu App Router. Para saberlo, muestraversioncomo arriba. - Las funciones de React dependen de tu versión de Next, no de la de React.
- Para recibir un parche de seguridad de React hay que actualizar
next. Actualizar soloreactno toca esa copia. Es la lección central del apartado 13.
Estructura del proyecto
En el App Router, las carpetas de app/ definen las URL, y unos nombres de fichero reservados definen qué papel juega cada uno:
empresa-next/
├── app/
│ ├── layout.tsx Layout raíz: <html> y <body>
│ ├── page.tsx La ruta /
│ ├── not-found.tsx Pantalla 404
│ ├── globals.css
│ ├── departamentos/
│ │ ├── page.tsx /departamentos
│ │ ├── acciones.ts Server Functions
│ │ ├── FormularioDepartamento.tsx
│ │ └── [id]/
│ │ └── page.tsx /departamentos/10
│ ├── empleados/
│ ├── panel/
│ ├── login/
│ └── admin/
├── lib/
│ ├── api.ts Acceso a la API, solo servidor
│ └── sesion.ts Sesión, solo servidor
├── proxy.ts Se ejecuta antes de cada ruta indicada
├── next.config.ts
└── .env.local
Un fichero dentro de app/ que no tenga un nombre reservado no crea ninguna ruta. Por eso puedes tener FormularioDepartamento.tsx y acciones.ts junto a la página que los usa.
| Fichero | Para qué sirve |
|---|---|
page.tsx | Hace pública una ruta |
layout.tsx | Envuelve a sus páginas y se conserva al navegar |
loading.tsx | Interfaz de carga: una <Suspense> automática alrededor de la página |
error.tsx | Frontera de error del segmento. Debe ser Client Component |
not-found.tsx | Lo que se muestra al llamar a notFound() |
route.ts | Punto de entrada HTTP propio (GET, POST…), sin interfaz |
template.tsx | Como layout, pero se vuelve a montar en cada navegación |
5. El App Router
El layout raíz es obligatorio y es el único que contiene <html> y <body>. En esta aplicación es un Server Component:
// app/layout.tsx
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import Link from 'next/link'
import './globals.css'
export const metadata: Metadata = {
title: { default: 'EMPRESA', template: '%s | EMPRESA' },
description: 'Gestión de la empresa de ejemplo',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="es">
<body>
<header className="cabecera">
<strong>EMPRESA</strong>
<Link href="/">Inicio</Link>
<Link href="/departamentos">Departamentos</Link>
<Link href="/empleados">Empleados</Link>
<Link href="/panel">Panel</Link>
<Link href="/admin">Admin</Link>
</header>
<main>{children}</main>
</body>
</html>
)
}
Los puntos que debes retener:
<Link>hace navegación de cliente. No recarga el documento: pide un payload RSC y lo funde. Además hace prefetch de los enlaces visibles. Con un<a>normal perderías todo eso.- El layout no se vuelve a renderizar al navegar entre sus páginas. Por eso no sirve para comprobar permisos en cada visita.
metadataes la forma de indicar título y descripción. Una constante si es fija, o la funcióngenerateMetadatasi depende de datos. El resto es el mismo sistema de metadatos de documento del módulo 07.
Sustituye app/globals.css por estas reglas, que usan todos los ejemplos:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
}
.cabecera {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
padding: 0.75rem 1.25rem;
background: #283593;
}
.cabecera a,
.cabecera strong {
color: #fff;
text-decoration: none;
}
main {
padding: 1.25rem;
max-width: 760px;
}
h1 {
margin: 0 0 1rem;
font-size: 1.5rem;
color: #1a237e;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.carga {
color: #607d8b;
background: #eceff1;
border-style: dashed;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.error {
color: #c62828;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #e0e0e0;
}
button,
input {
font: inherit;
padding: 0.35rem 0.6rem;
border: 1px solid #3f51b5;
border-radius: 6px;
}
button {
background: #fff;
color: #283593;
cursor: pointer;
}
button[disabled] {
opacity: 0.6;
}
Y añade una página para las rutas que no existen:
// app/not-found.tsx
import Link from 'next/link'
export default function NoEncontrado() {
return (
<div className="tarjeta">
<h2>No existe esa página</h2>
<Link href="/">Volver al inicio</Link>
</div>
)
}
Rutas dinámicas y params asíncronos
Una carpeta con corchetes captura un trozo de la URL. Ojo: params y searchParams son promesas. Desde Next 16 no queda el acceso síncrono que Next 15 aún toleraba, así que hay que usar await:
// app/departamentos/[id]/page.tsx
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import {
obtenerDepartamento,
obtenerDepartamentos,
obtenerEmpleadosDe,
} from '@/lib/api'
type Props = { params: Promise<{ id: string }> }
// Lista de ids que se prerenderizan al compilar
export async function generateStaticParams() {
const departamentos = await obtenerDepartamentos()
return departamentos.map((d) => ({ id: String(d.deptId) }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
return { title: departamento?.nombre ?? 'No encontrado' }
}
async function Empleados({ deptId }: { deptId: number }) {
const { contenido, total } = await obtenerEmpleadosDe(deptId)
return (
<div className="tarjeta">
<h2>{total} empleados</h2>
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.nombre} {e.apellidos}, {e.oficio}
</li>
))}
</ul>
</div>
)
}
export default async function PaginaDepartamento({ params }: Props) {
const { id } = await params
const departamento = await obtenerDepartamento(Number(id))
if (!departamento) notFound()
return (
<>
<h1>{departamento.nombre}</h1>
<p>Sede en {departamento.localidad}</p>
<Suspense fallback={<p className="tarjeta carga">Cargando empleados...</p>}>
<Empleados deptId={departamento.deptId} />
</Suspense>
</>
)
}
notFound() lanza una señal interna que corta el render y muestra not-found.tsx. Las funciones de lib/api las escribes en el apartado siguiente.
6. Server Components en la práctica
Agrupa el acceso a la API en un módulo. Fíjate en la primera línea:
// lib/api.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
export type Departamento = {
deptId: number
nombre: string
localidad: string
presupuesto: number | null
}
export type Empleado = {
empId: number
nombre: string
apellidos: string
oficio: string
salario: number
deptId: number
departamento: string
}
export type Pagina<T> = {
contenido: T[]
pagina: number
tamano: number
total: number
paginas: number
}
export class ErrorApi extends Error {
constructor(
readonly estado: number,
mensaje: string,
readonly detalles?: Record<string, string>,
) {
super(mensaje)
}
}
export async function pedir<T>(ruta: string, opciones?: RequestInit): Promise<T> {
const respuesta = await fetch(`${API}${ruta}`, opciones)
if (!respuesta.ok) {
const cuerpo = await respuesta.json().catch(() => null)
throw new ErrorApi(
respuesta.status,
cuerpo?.mensaje ?? respuesta.statusText,
cuerpo?.detalles,
)
}
return respuesta.status === 204 ? (undefined as T) : respuesta.json()
}
export async function obtenerDepartamentos() {
'use cache'
cacheLife('minutes')
cacheTag('departamentos')
const datos = await pedir<Pagina<Departamento>>('/departamentos?tamano=100')
return datos.contenido
}
export async function obtenerDepartamento(id: number) {
'use cache'
cacheLife('minutes')
cacheTag('departamentos', `departamento-${id}`)
try {
return await pedir<Departamento>(`/departamentos/${id}`)
} catch (error) {
if (error instanceof ErrorApi && error.estado === 404) return null
throw error
}
}
export async function obtenerEmpleadosDe(deptId: number) {
'use cache'
cacheLife('minutes')
cacheTag('empleados')
return pedir<Pagina<Empleado>>(`/departamentos/${deptId}/empleados?tamano=100`)
}
export async function obtenerEstadisticas() {
'use cache'
cacheLife('hours')
cacheTag('estadisticas')
return pedir<Record<string, unknown>>('/estadisticas')
}
El tipo presupuesto: number | null no es un capricho: un departamento de la API no tiene presupuesto. Si lo tipas como number, el build falla al prerenderizar con Cannot read properties of null. Validar tipos contra los datos reales con Zod, como en el módulo 13, habría avisado de esto antes.
La directiva 'use cache' y las funciones cacheLife y cacheTag se explican en el apartado 11. Por ahora, basta con saber que el resultado queda guardado.
server-only: que no se te escape nada
import 'server-only' convierte un descuido en un error de compilación. Si un Client Component importa este módulo, directa o indirectamente, el build falla:
You're importing a module that depends on "server-only" into a React Client
Component module. This API is only available in Server Components but one of
its parents is marked with "use client", so this module is also a Client
Component.
En Next instalar el paquete es opcional: el framework lo reconoce por sí mismo. El ejemplo de este módulo compila sin instalarlo. Si tu lint se queja de una dependencia no declarada, añádelo con npm install server-only.
Variables de entorno
| Variable | Dónde existe | Cuándo se resuelve |
|---|---|---|
API_URL | Solo en el servidor | En tiempo de ejecución |
NEXT_PUBLIC_API_URL | En servidor y cliente | Se incrusta en el JavaScript al compilar |
⚠️ Importante: Una variable con prefijo
NEXT_PUBLIC_acaba escrita en un fichero público. Nunca pongas ahí una clave. En el lado contrario,process.env.API_URLen un Client Component valeundefined, sin error alguno: es el fallo silencioso más habitual al mover código al cliente.
La primera página con datos
// app/departamentos/page.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import { obtenerDepartamentos } from '@/lib/api'
import { FormularioDepartamento } from './FormularioDepartamento'
export const metadata: Metadata = { title: 'Departamentos' }
export default async function PaginaDepartamentos() {
const departamentos = await obtenerDepartamentos()
return (
<>
<h1>Departamentos</h1>
<div className="tarjeta">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Localidad</th>
<th>Presupuesto</th>
</tr>
</thead>
<tbody>
{departamentos.map((d) => (
<tr key={d.deptId}>
<td>
<Link href={`/departamentos/${d.deptId}`}>{d.nombre}</Link>
</td>
<td>{d.localidad}</td>
<td>
{d.presupuesto === null
? 'Sin asignar'
: `${d.presupuesto.toLocaleString('es-ES')} €`}
</td>
</tr>
))}
</tbody>
</table>
</div>
<FormularioDepartamento />
</>
)
}
FormularioDepartamento llegará en el apartado 9. El componente hace todo su trabajo en el servidor y solo llega al navegador su resultado. Comprobado en el build de este módulo: los ficheros de .next/static, los que se descargan los usuarios, no contienen /auth/yo, ni tamano=100, ni la URL de la API.
7. La frontera 'use client'
Un Server Component no puede tener interactividad. Si lo intentas, no compila:
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.
La solución es esa directiva, pero hay que entender bien qué hace.
'use client' no marca un componente: marca una frontera en el grafo de módulos. El fichero que la lleva y todo lo que ese fichero importa pasan al entorno cliente. No hace falta repetirla en cada archivo.
Un buscador es un buen ejemplo. El campo reacciona a la escritura y por eso debe estar en el cliente, pero los resultados no:
// app/empleados/Buscador.tsx
'use client'
import { useTransition } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export function Buscador() {
const router = useRouter()
const ruta = usePathname()
const parametros = useSearchParams()
const [pendiente, iniciar] = useTransition()
function buscar(texto: string) {
const nuevos = new URLSearchParams(parametros)
if (texto) nuevos.set('q', texto)
else nuevos.delete('q')
iniciar(() => router.replace(`${ruta}?${nuevos}`))
}
return (
<div className="fila">
<input
type="search"
aria-label="Buscar empleado"
placeholder="Buscar..."
defaultValue={parametros.get('q') ?? ''}
onChange={(evento) => buscar(evento.target.value)}
/>
{pendiente && <span>Actualizando...</span>}
</div>
)
}
// app/empleados/page.tsx
import { Suspense } from 'react'
import { pedir, type Empleado, type Pagina } from '@/lib/api'
import { Buscador } from './Buscador'
type Props = { searchParams: Promise<{ q?: string }> }
async function Resultados({ searchParams }: Props) {
const { q = '' } = await searchParams
const { contenido, total } = await pedir<Pagina<Empleado>>(
`/empleados?q=${encodeURIComponent(q)}&tamano=8&orden=apellidos`,
)
return (
<div className="tarjeta">
<h2>{total} resultados</h2>
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.apellidos}, {e.nombre} ({e.departamento})
</li>
))}
</ul>
</div>
)
}
export default function PaginaEmpleados({ searchParams }: Props) {
return (
<>
<h1>Empleados</h1>
<Suspense>
<Buscador />
</Suspense>
<Suspense fallback={<p className="tarjeta carga">Buscando...</p>}>
<Resultados searchParams={searchParams} />
</Suspense>
</>
)
}
La búsqueda vive en la URL, así que se puede compartir y funciona el botón Atrás. Al escribir, router.replace dentro de una transición (módulo 15) pide un nuevo payload y la lista se actualiza sin que tengas ningún estado de datos en el cliente.

Figura 5. El campo es un Client Component; la lista se calcula en el servidor.
Fíjate en un detalle: PaginaEmpleados no hace await de searchParams. Solo le pasa la promesa a Resultados, que está dentro de Suspense. Por eso la página puede prerenderizar el título y el contorno aunque no conozca la búsqueda. También Buscador va dentro de Suspense, porque useSearchParams depende de la URL de cada petición.
💡 Consejo: Lleva
'use client'lo más abajo posible en el árbol. Si marcas la página entera, todo lo que importe viaja al navegador y pierdes la ventaja de los Server Components. Marca solo la hoja interactiva: el botón, el campo, el desplegable.
Qué puede cruzar la frontera
Las props de un Server Component a un Client Component se serializan, así que no todo vale:
| Tipo | ¿Pasa? |
|---|---|
Texto, números, booleanos, null, undefined, bigint | Sí |
| Objetos planos y arrays | Sí |
Date, Map, Set, typed arrays | Sí |
| Promesas | Sí, y se leen con use |
Elementos JSX (children) | Sí |
| Server Functions | Sí |
| Funciones normales | No |
| Instancias de clases propias | No |
Los dos errores reales:
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}>
Only plain objects, and a few built-ins, can be passed to Client Components
from Server Components. Classes or null prototypes are not supported.
<... datos={{nombre: "Ana"}}>
⚠️ Importante: Todo lo que pasas como prop llega al navegador, aunque el componente no lo pinte. Si pasas el empleado entero a un botón, el salario queda a la vista en el HTML. Pasa solo lo que haga falta.
Componer: Server dentro de Client
Una regla que parece una limitación: un Client Component no puede importar un Server Component. Si lo hace, ese módulo se convierte en código cliente. Pero sí puede recibirlo como children, porque quien lo crea es el servidor:
// app/productos/Desplegable.tsx
'use client'
import { useState, type ReactNode } from 'react'
type Props = { titulo: string; children: ReactNode }
export function Desplegable({ titulo, children }: Props) {
const [abierto, setAbierto] = useState(false)
return (
<>
<button aria-expanded={abierto} onClick={() => setAbierto(!abierto)}>
{titulo}
</button>
{abierto && children}
</>
)
}
// En un Server Component
<Desplegable titulo="Ver detalle">
<p>{producto.descripcion}</p>
</Desplegable>
Desplegable no sabe qué lleva dentro. Recibe un trozo de interfaz ya resuelto en el servidor. Es la técnica de composición del módulo 03 aplicada a dos entornos.
Tampoco puede ser async un Client Component. En desarrollo verás este aviso:
<Lista> 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.
Si necesitas datos en un Client Component, recíbelos por props desde un Server Component, léelos con use (apartado 10) o usa TanStack Query como en el módulo 19.
Bibliotecas de terceros
Muchas bibliotecas usan useState o useEffect sin llevar 'use client', y al importarlas desde un Server Component dan el error de arriba. La solución es un fichero intermedio:
'use client'
export { Carrusel } from 'biblioteca-de-carruseles'
8. Context y Server Components
Context depende del árbol de React en vivo, algo que el servidor no conserva entre componentes como lo hace el cliente. Esto tiene tres consecuencias que conviene conocer.
No puedes crear un contexto en un Server Component. createContext no está disponible ahí:
You're importing a module that depends on `createContext` into a React Server
Component module. This API is only available in Client Components.
No puedes leer un contexto en un Server Component. Ni useContext ni use(Contexto). Esto último compila, porque use existe en el entorno servidor para leer promesas, pero falla en ejecución:
import { use } from 'react'
import { Contexto } from './contexto' // Fichero con 'use client'
function Lector() {
const valor = use(Contexto) // Error en el servidor
return <p>Leído: {valor}</p>
}
Cannot use() an already resolved Client Reference.
La explicación está en el payload: desde el servidor, Contexto no es un contexto, sino una referencia a un módulo cliente. Para compartir datos entre Server Components no uses Context. Pásalos por props, o llama a la misma función de datos en cada uno: use cache y la función cache de React evitan repetir el trabajo.
Sí puedes proporcionar un contexto a los Client Components que haya por debajo.
Renderizar <Context> en el servidor 🆕 19.3
🆕 Desde React 19.3. Un Server Component puede renderizar directamente un
<Context>importado de un módulo'use client'. No existe en React 19.2 ni anteriores. Si usas una versión anterior: crea un Client Component envoltorio, comoTemaProvider, que recibavalueychildreny renderice el contexto, e impórtalo desde el servidor.
// app/contexto/tema.ts
'use client'
import { createContext } from 'react'
export const TemaContexto = createContext<'claro' | 'oscuro'>('claro')
// app/contexto/Etiqueta.tsx
'use client'
import { use } from 'react'
import { TemaContexto } from './tema'
export function Etiqueta() {
const tema = use(TemaContexto)
return <p>Tema activo: {tema}</p>
}
// app/contexto/page.tsx
import { TemaContexto } from './tema'
import { Etiqueta } from './Etiqueta'
export default function PaginaContexto() {
return (
<TemaContexto value="oscuro">
<div className="tarjeta">
<Etiqueta />
</div>
</TemaContexto>
)
}
Resultado medido: Tema activo: oscuro. Funciona porque la canary que incluye Next 16.4.3 ya lleva este cambio. En un proyecto de Next, recuerda, lo que manda es esa copia.
Antes de 19.3 hacía falta este componente:
// app/contexto/TemaProvider.tsx
'use client'
import type { ReactNode } from 'react'
import { TemaContexto } from './tema'
type Props = { valor: 'claro' | 'oscuro'; children: ReactNode }
export function TemaProvider({ valor, children }: Props) {
return <TemaContexto value={valor}>{children}</TemaContexto>
}
9. 'use server' y Server Functions 🆕 19.0
🆕 Desde React 19.0. No existen como función estable en React 18.3 ni anteriores. Si usas una versión anterior: crea un punto de entrada en tu API y llámalo con
fetchdesde un manejador, como hiciste en el módulo 16.
Hay una confusión de nombres muy extendida que conviene aclarar de una vez:
'use client'y'use server'no son opuestos.'use client'marca qué código va al navegador.'use server'marca funciones que el navegador puede llamar y que se ejecutan en el servidor.'use server'no sirve para declarar un Server Component. Los Server Components no llevan directiva: son el valor por defecto.- Server Function es el nombre general. Server Action es una Server Function usada en un
actionde formulario o en una Action. Verás ambos términos en la documentación.
Una Server Function es una especie de llamada remota: tú importas una función, y React y Next se encargan de que al llamarla se haga un POST al servidor con los argumentos serializados. Esta es la que crea departamentos:
// app/departamentos/acciones.ts
'use server'
import { updateTag } from 'next/cache'
import { z } from 'zod'
import { ErrorApi, pedir } from '@/lib/api'
import { usuarioActual } from '@/lib/sesion'
export type EstadoFormulario = {
mensaje: string
errores?: Record<string, string>
}
const esquema = z.object({
nombre: z.string().trim().min(2, 'Escribe al menos 2 letras').toUpperCase(),
localidad: z.string().trim().min(2, 'Escribe al menos 2 letras'),
presupuesto: z.coerce.number().positive('Debe ser mayor que cero'),
})
export async function crearDepartamento(
_anterior: EstadoFormulario,
datos: FormData,
): Promise<EstadoFormulario> {
// 1. Quién llama
const usuario = await usuarioActual()
if (!usuario) return { mensaje: 'Inicia sesión para crear departamentos' }
// 2. Qué envía
const resultado = esquema.safeParse(Object.fromEntries(datos))
if (!resultado.success) {
const errores: Record<string, string> = {}
for (const fallo of resultado.error.issues) {
errores[String(fallo.path[0])] = fallo.message
}
return { mensaje: 'Revisa los datos', errores }
}
// 3. Hacer el trabajo
try {
await pedir('/departamentos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${usuario.token}`,
},
body: JSON.stringify(resultado.data),
})
} catch (error) {
if (error instanceof ErrorApi) {
return { mensaje: error.message, errores: error.detalles }
}
throw error
}
// 4. Avisar a la caché
updateTag('departamentos')
return { mensaje: `Departamento ${resultado.data.nombre} creado` }
}
En un fichero con 'use server' arriba, todas las exportaciones deben ser funciones async. Si exportas otra cosa, no compila:
Only async functions are allowed to be exported in a "use server" file.
Exportar un type sí está permitido, porque desaparece al compilar. Es el caso de EstadoFormulario.
El formulario es un Client Component que usa useActionState, tal como en el módulo 16. La novedad es que la función está en el servidor:
// app/departamentos/FormularioDepartamento.tsx
'use client'
import { useActionState } from 'react'
import { crearDepartamento, type EstadoFormulario } from './acciones'
const inicial: EstadoFormulario = { mensaje: '' }
export function FormularioDepartamento() {
const [estado, accion, pendiente] = useActionState(crearDepartamento, inicial)
return (
<form action={accion} className="tarjeta">
<h2>Nuevo departamento</h2>
<div className="fila">
<input name="nombre" placeholder="Nombre" aria-label="Nombre" />
<input name="localidad" placeholder="Localidad" aria-label="Localidad" />
<input
name="presupuesto"
type="number"
placeholder="Presupuesto"
aria-label="Presupuesto"
/>
<button disabled={pendiente}>{pendiente ? 'Guardando...' : 'Crear'}</button>
</div>
{estado.mensaje && <p role="status">{estado.mensaje}</p>}
{estado.errores &&
Object.entries(estado.errores).map(([campo, error]) => (
<p key={campo} className="error">
{campo}: {error}
</p>
))}
</form>
)
}

Figura 6. Los mensajes de error los produce Zod en el servidor y llegan como estado.
Al crear uno, el navegador hace un solo POST a /departamentos, y esa misma respuesta trae el resultado de la función y la lista actualizada. No hay una segunda petición para refrescar. Eso es lo que hace updateTag dentro de una Server Function.

Figura 7. El nuevo departamento aparece en la tabla con una sola petición.
Una sesión con cookies
La API usa tokens JWT. En una SPA con Vite, el token tiene que vivir en el navegador, al alcance del JavaScript de la página. Con un servidor propio hay una opción mejor: guardarlo en una cookie httpOnly, que JavaScript no puede leer.
// lib/sesion.ts
import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { pedir } from './api'
type Usuario = { email: string; nombre: string; rol: 'ADMIN' | 'USER' }
export const COOKIE_SESION = 'sesion'
export async function usuarioActual() {
const token = (await cookies()).get(COOKIE_SESION)?.value
if (!token) return null
try {
const usuario = await pedir<Usuario>('/auth/yo', {
headers: { Authorization: `Bearer ${token}` },
})
return { ...usuario, token }
} catch {
return null
}
}
export async function exigirAdmin() {
const usuario = await usuarioActual()
if (!usuario) redirect('/login')
if (usuario.rol !== 'ADMIN') throw new Error('Tu rol no permite esta operación')
return usuario
}
// app/login/acciones.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { ErrorApi, pedir } from '@/lib/api'
import { COOKIE_SESION } from '@/lib/sesion'
export async function iniciarSesion(_anterior: string | null, datos: FormData) {
let respuesta: { token: string }
try {
respuesta = await pedir('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: datos.get('email'),
password: datos.get('password'),
}),
})
} catch (error) {
if (error instanceof ErrorApi && error.estado === 401) {
return 'Correo o contraseña incorrectos'
}
throw error
}
const almacen = await cookies()
almacen.set(COOKIE_SESION, respuesta.token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 3600,
path: '/',
})
redirect('/admin')
}
export async function cerrarSesion() {
const almacen = await cookies()
almacen.delete(COOKIE_SESION)
redirect('/login')
}
// app/login/FormularioLogin.tsx
'use client'
import { useActionState } from 'react'
import { iniciarSesion } from './acciones'
export function FormularioLogin() {
const [error, accion, pendiente] = useActionState(iniciarSesion, null)
return (
<form action={accion} className="tarjeta">
<div className="fila">
<input name="email" type="email" aria-label="Correo" placeholder="Correo" />
<input name="password" type="password" aria-label="Contraseña" placeholder="Contraseña" />
<button disabled={pendiente}>Entrar</button>
</div>
{error && <p className="error">{error}</p>}
</form>
)
}
// app/login/page.tsx
import { FormularioLogin } from './FormularioLogin'
export default function PaginaLogin() {
return (
<>
<h1>Iniciar sesión</h1>
<FormularioLogin />
</>
)
}
Dos detalles. redirect funciona lanzando una señal interna, así que no lo metas dentro de un try que capture todo: por eso aquí va al final, fuera del bloque. Y las cookies solo se pueden modificar en una Server Function o en un route.ts, nunca durante el render de un Server Component.
La cookie queda así en el navegador: httpOnly, sameSite=Lax y, en producción, secure. Las pruebas de este módulo la han comprobado con Playwright.
Una Server Function es un punto de entrada público
Esta es la idea más importante del apartado. Parece una función privada de tu aplicación, pero cualquiera puede invocarla con una petición HTTP, con los argumentos que quiera, sin pasar por tu formulario ni por tu botón.
Compruébalo. Cada Server Function tiene un identificador, que el navegador envía en la cabecera Next-Action y que aparece en .next/server/server-reference-manifest.json. Tomemos invalidarReloj, una función del apartado 11 que no comprueba nada y que solo se usa en la página /cache. Esta orden no tiene cookies, no abre esa página y ni siquiera apunta a esa ruta:
curl -i -X POST http://localhost:3001/ \
-H "Next-Action: <identificador>" \
-H "Accept: text/x-component" \
-H "Content-Type: text/plain;charset=UTF-8" \
-H "Origin: http://localhost:3001" \
-d "[]"
| Petición, sin cookie | Resultado medido |
|---|---|
POST / con el identificador de invalidarReloj | 200 y x-action-revalidated: 1: la función se ha ejecutado |
POST /departamentos con el mismo identificador | Igual: se ha ejecutado |
POST /admin, la ruta que cubre proxy.ts | 307 a /login |
Un desconocido acaba de vaciar una entrada de tu caché con una sola línea. Aquí el daño es pequeño, pero imagina que esa función borrara datos.
La última fila explica una trampa más. proxy trabaja con rutas, y la documentación de Next avisa de que una Server Function es un POST a la ruta donde se usa, por lo que un matcher que no cubra esa ruta tampoco la protege. Un cambio en el matcher, o mover la función de sitio, puede dejarla sin cobertura sin que nadie lo note. Por eso la comprobación debe ir dentro de la función.
De ahí las tres preguntas que toda Server Function debe responder, en este orden:
- ¿Quién llama? Comprueba la sesión dentro de la función. Que el botón esté oculto o que la página esté protegida no sirve.
- ¿Puede hacerlo? Comprueba el rol y que el recurso sea suyo.
- ¿Qué envía? Valida con Zod. TypeScript desaparece al compilar: los tipos de los argumentos no protegen de nada en tiempo de ejecución.
Next añade por su cuenta dos defensas: las Server Functions solo admiten POST, y compara las cabeceras Origin y Host para frenar el CSRF. Las defensas de autorización y validación son tuyas.
Errores esperados e inesperados
¿Qué pasa si la API rechaza la operación? Esta primera versión del borrado no captura nada:
// app/admin/acciones.ts
'use server'
import { updateTag } from 'next/cache'
import { pedir } from '@/lib/api'
import { exigirAdmin } from '@/lib/sesion'
export async function borrarDepartamento(datos: FormData) {
const usuario = await exigirAdmin()
const id = Number(datos.get('id'))
if (!Number.isInteger(id)) throw new Error('Identificador no válido')
await pedir(`/departamentos/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${usuario.token}` },
})
updateTag('departamentos')
}
Borrar VENTAS, que tiene empleados, devuelve un 409. La excepción sale de la Server Function, y en producción el usuario ve esto:
This page couldn't load
A server error occurred. Reload to try again.
ERROR 977169741
El texto real (“No se puede borrar un departamento con empleados”) solo aparece en el registro del servidor. En producción, Next oculta los mensajes de error del servidor y envía un código llamado digest, para no filtrar datos internos. Es lo correcto para un fallo inesperado, pero un 409 no lo es: forma parte del negocio.
La regla es esta:
- Error esperado (validación, conflicto, credenciales): devuélvelo como valor.
useActionStatelo recibe y lo muestra. - Error inesperado (la API caída, un fallo de programación): déjalo escapar. Lo captura el
error.tsxmás cercano.
El ejercicio 8 aplica esta regla al borrado.
10. Streaming
Una página con varias consultas tiene un problema: si una tarda 2 segundos, ¿espera toda la página? Con streaming, no. El servidor envía la respuesta HTTP por trozos: primero lo que ya tiene, con los fallback de <Suspense>, y luego el resto según esté listo.
// app/panel/page.tsx
import { Suspense } from 'react'
import { pedir, type Pagina } from '@/lib/api'
import { TablaPedidos, type Pedido } from './TablaPedidos'
const espera = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function Rapido() {
await espera(200)
const { total } = await pedir<Pagina<unknown>>('/empleados?tamano=1')
return (
<div className="tarjeta">
<h2>Plantilla</h2>
<p>{total} empleados</p>
</div>
)
}
async function Lento() {
await espera(1500)
const { total } = await pedir<Pagina<unknown>>('/proyectos?tamano=1')
return (
<div className="tarjeta">
<h2>Proyectos</h2>
<p>{total} proyectos</p>
</div>
)
}
async function cargarPedidos() {
await espera(800)
const { contenido } = await pedir<Pagina<Pedido>>('/pedidos?tamano=5')
return contenido
}
export default function PaginaPanel() {
// Sin await: se crea la promesa y se pasa tal cual
const pedidos = cargarPedidos()
return (
<>
<h1>Panel</h1>
<Suspense fallback={<p className="tarjeta carga">Contando empleados...</p>}>
<Rapido />
</Suspense>
<Suspense fallback={<p className="tarjeta carga">Contando proyectos...</p>}>
<Lento />
</Suspense>
<Suspense fallback={<p className="tarjeta carga">Cargando pedidos...</p>}>
<TablaPedidos pedidos={pedidos} />
</Suspense>
</>
)
}
Estos son los tiempos reales de una petición a /panel, con el servidor en caliente, leyendo el cuerpo de la respuesta trozo a trozo:
| Tiempo | Lo que llega |
|---|---|
| 23 ms | El armazón: cabecera, título y los tres fallback |
| 306 ms | Plantilla |
| 1036 ms | Pedidos |
| 1600 ms | Proyectos, y fin de la respuesta |
Sin streaming, la pantalla seguiría en blanco 1600 ms. Con él, el usuario ve la estructura a los 23 ms. Como las tres partes se ejecutan a la vez, el total es el de la más lenta, no la suma.

Figura 8. El estado intermedio: dos partes han llegado y la más lenta aún muestra su fallback.

Figura 9. El mismo panel cuando la respuesta ha terminado.
¿Cómo llega un trozo tardío al lugar correcto? El HTML posterior viene acompañado de un pequeño <script> que lo coloca donde estaba el fallback. Por eso la página funciona aunque el JavaScript principal no haya cargado aún.
📘 Concepto: Bajo el streaming está lo que viste en el módulo 15: cuando un componente espera, React muestra la
<Suspense>más cercana. En el servidor, un Server Componentasynchace de componente que espera.
Pasar una promesa al cliente con use 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: resuelve los datos en el Server Component y pásalos como prop normal. Pierdes el streaming de esa parte.
PaginaPanel no espera los pedidos: crea la promesa y se la pasa a un Client Component, que la lee con use:
// app/panel/TablaPedidos.tsx
'use client'
import { use, useState } from 'react'
export type Pedido = {
pedidoId: number
estado: string
fechaPedido: string
}
export function TablaPedidos({ pedidos }: { pedidos: Promise<Pedido[]> }) {
const lista = use(pedidos)
const [soloPendientes, setSoloPendientes] = useState(false)
const visibles = soloPendientes
? lista.filter((p) => p.estado === 'PENDIENTE')
: lista
return (
<div className="tarjeta">
<h2>Últimos pedidos</h2>
<label>
<input
type="checkbox"
checked={soloPendientes}
onChange={(e) => setSoloPendientes(e.target.checked)}
/>{' '}
Solo pendientes
</label>
<ul>
{visibles.map((p) => (
<li key={p.pedidoId}>
{p.pedidoId}: {p.estado}
</li>
))}
</ul>
</div>
)
}
Aquí se resuelve, por fin, la regla del módulo 15 de que la promesa debe ser estable: la crea el servidor, una sola vez, y la transporta el payload. Puedes combinar datos del servidor con interactividad del cliente sin bloquear la página.
loading.tsx y el código de estado
Si un fichero loading.tsx está en una carpeta, Next envuelve la página de ese segmento en una <Suspense> con ese fallback. Es cómodo cuando toda la página espera, y también da respuesta inmediata al navegar. <Suspense> a mano te deja elegir mejor qué se bloquea.
// app/departamentos/loading.tsx
export default function Cargando() {
return <p className="tarjeta carga">Cargando...</p>
}
⚠️ Importante: El streaming tiene un coste: el código de estado HTTP se envía al principio, antes de saber cómo acaba el render. La ficha del ejercicio 2 llama a
notFound()dentro deSuspense, y esto es lo medido:/empleados/999responde con estado200y muestra la página 404, mientras que una ruta que no existe,/no-existe, responde404. Para que los buscadores no indexen esa URL, Next añade<meta name="robots" content="noindex"/>al HTML, y también eso se ha comprobado. Lo mismo vale pararedirectdentro deSuspense: es lo que ocurre enZonaPrivada. Si un cliente de tu sitio depende del código de estado, tenlo en cuenta.
11. La caché
Este es el apartado que más ha cambiado en la historia de Next. Antes de seguir, olvida lo que hayas leído sobre la caché de Next en un tutorial antiguo. Next 16.4 tiene dos modelos, y los elige una opción de configuración:
Figura 10. El modelo con cacheComponents es el recomendado y el que usa create-next-app.
Next documenta que cacheComponents y partialPrefetching estarán activos en todas partes en la próxima versión mayor, y que esas opciones desaparecerán. Por eso este módulo enseña primero Cache Components, y deja el modelo anterior para quien mantenga código existente.
Cache Components: dinámico salvo que digas lo contrario
La regla es fácil de enunciar: ningún dato se cachea si no lo pides. Cuando Next compila, intenta prerenderizar cada ruta. Al encontrar algo asíncrono, solo admite dos opciones:
'use cache': el resultado se guarda y entra en el armazón estático.<Suspense>: se envía el fallback en el armazón y lo demás llega por streaming en cada petición.
Si no eliges ninguna, el build falla. Es una decisión de diseño: te obliga a pensarlo. El error real es este:
Error: Route "/b1": Next.js encountered uncached or runtime data during
prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`,
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] For uncached data (`fetch`, database calls): cache the access with
"use cache" (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
El mensaje ya te da las salidas. La tercera, instant = false, acepta que esa ruta bloquee la carga: úsala solo si de verdad no hay nada que enseñar antes.
Un componente puede combinar lo dinámico con lo cacheado en la misma ruta. Por eso, en esta tabla real de next build, casi todas las rutas son ○ o ◐, y ninguna es ƒ:
Route (app) Revalidate Expire
┌ ○ / 1h 1d
├ ○ /_not-found
├ ◐ /admin
├ ◐ /cache 1m 1h
├ ○ /contexto
├ ○ /departamentos 1m 1h
├ /departamentos/[id]
│ ├ ◐ /departamentos/[id]
│ ├ ○ /departamentos/10 1m 1h
│ ├ ○ /departamentos/20 1m 1h
│ └ ◐ [+5 more paths]
├ ◐ /empleados
├ ○ /login
├ ◐ /panel
└ ○ /version
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
◐ es PPR: un armazón estático y huecos dinámicos. Ya lo has medido en /panel.
'use cache'
La directiva se pone al principio de una función async, de un componente o de un fichero, y guarda su valor de retorno. Puede cachear datos:
export async function obtenerDepartamentos() {
'use cache'
cacheLife('minutes')
cacheTag('departamentos')
const datos = await pedir<Pagina<Departamento>>('/departamentos?tamano=100')
return datos.contenido
}
O interfaz, como el componente RelojCacheado de este ejemplo, que sirve para ver la caché en acción:
// app/cache/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import { cacheLife, cacheTag } from 'next/cache'
import { invalidarReloj } from './acciones'
async function RelojCacheado() {
'use cache'
cacheLife({ stale: 30, revalidate: 60, expire: 3600 })
cacheTag('reloj')
return <p>Cacheado: {new Date().toISOString()}</p>
}
async function RelojDinamico() {
await connection()
return <p>Dinámico: {new Date().toISOString()}</p>
}
export default function PaginaCache() {
return (
<div className="tarjeta">
<RelojCacheado />
<Suspense fallback={<p className="carga">Esperando...</p>}>
<RelojDinamico />
</Suspense>
<form action={invalidarReloj}>
<button>Invalidar</button>
</form>
</div>
)
}
// app/cache/acciones.ts
'use server'
import { updateTag } from 'next/cache'
export async function invalidarReloj() {
updateTag('reloj')
}
Esto es lo que sale al recargar en producción, dos veces con 1,5 s de diferencia:
Cacheado: 2026-11-02T06:12:00.689Z Dinámico: 2026-11-02T06:12:02.206Z
Cacheado: 2026-11-02T06:12:00.689Z Dinámico: 2026-11-02T06:12:04.179Z
El reloj cacheado no se mueve; el dinámico sí. Al pulsar Invalidar cambian los dos.

Figura 11. Dos relojes en la misma página: uno se guarda, otro se calcula en cada petición.
💡 Consejo:
connection()indica que lo que sigue debe esperar a una petición real. Es la forma de sacar del prerender algo que no lee datos externos, como una hora. Si no lo pones,new Date()falla en el prerender, como verás enseguida.
Las tres duraciones de cacheLife
| Propiedad | Quién la usa | Qué significa |
|---|---|---|
stale | El navegador | Tiempo que puede usar su copia sin preguntar al servidor |
revalidate | El servidor | A partir de aquí, sirve la copia y genera otra en segundo plano |
expire | El servidor | Sin visitas durante este tiempo, la entrada deja de valer y la próxima petición espera |
Puedes indicarlas a mano o usar los perfiles predefinidos:
| Perfil | stale | revalidate | expire |
|---|---|---|---|
default | 5 min | 15 min | nunca |
seconds | 30 s | 1 s | 1 min |
minutes | 5 min | 1 min | 1 h |
hours | 5 min | 1 h | 1 día |
days | 5 min | 1 día | 1 semana |
weeks | 5 min | 1 semana | 30 días |
max | 5 min | 30 días | 1 año |
El comportamiento stale-while-revalidate se ve en los ejemplos: al abrir /cache casi dos minutos después de compilar, la primera visita recibió la hora de la compilación, y la siguiente ya tenía una nueva. Nadie tuvo que esperar: esa primera visita recibió la copia al instante mientras otra se generaba detrás. Esta política también aparece en las cabeceras reales:
| Ruta | Cache-Control medido |
|---|---|
/contexto | s-maxage=31536000 |
/departamentos | s-maxage=60, stale-while-revalidate=3540 |
/panel | private, no-cache, no-store, max-age=0, must-revalidate |
La clave de caché
Los argumentos de la función, y las variables que capture, forman parte de la clave. obtenerDepartamento(10) y obtenerDepartamento(20) son entradas distintas. Lo hace Next, sin que tengas que escribir ninguna clave.
Esto tiene una consecuencia: los argumentos deben ser serializables. Y otra más sutil: lo que no sea parte de los argumentos, no entra en la clave.
Datos de cada usuario
Una función con 'use cache' no puede leer cookies() ni headers(): el resultado se compartiría entre usuarios y uno vería los datos de otro. Next lo impide:
Error: Route "/b3": `cookies()` can't be read inside "use cache". Read it outside
the cached function and pass what you need as an argument.
La solución es leer el valor fuera y pasarlo como argumento, para que entre en la clave:
async function Perfil() {
const token = (await cookies()).get('sesion')?.value // Dentro de Suspense
return <PerfilCacheado token={token} />
}
async function PerfilCacheado({ token }: { token?: string }) {
'use cache'
cacheLife('minutes')
// ...
}
Hay además dos variantes de la directiva: 'use cache: private', para datos que dependen de la petición y que se quieren incluir en un prefetch, y 'use cache: remote', para una caché compartida en un servicio externo. La razón de esta última: por defecto la caché en ejecución vive en la memoria del proceso, de modo que en plataformas sin servidor (serverless) puede no sobrevivir entre peticiones.
Valores aleatorios y fechas
En un Server Component sin use cache, llamar a Math.random() o new Date() durante el prerender también rompe el build. Tiene su lógica: ese valor quedaría congelado para siempre. Tienes que decidir:
Error: Route "/b2": Next.js encountered the unstable value `Math.random()` while
prerendering. This value can change between renders, so it must be either
prerendered or computed later.
Ways to fix this:
- [dynamic] Render at request time by adding a dynamic data access
(e.g. `await connection()`) before this call
- [cache] Prerender and cache the value with "use cache"
- [client] Render the value on the client with "use client"
- Si debe ser distinto en cada petición:
await connection()y<Suspense>. - Si puede congelarse un tiempo: dentro de
'use cache', comoRelojCacheado. - Si es algo del navegador: en un Client Component, cuidando la hidratación del apartado 2.
Invalidar
| Función | Dónde | Efecto |
|---|---|---|
updateTag('etiqueta') | Solo Server Functions | Caduca al momento: quien hace el cambio ve sus datos nuevos |
revalidateTag('etiqueta', 'max') | Server Functions y route.ts | Marca como viejo y regenera en segundo plano |
revalidatePath('/ruta') | Server Functions y route.ts | Lo mismo, por ruta |
refresh() | Solo Server Functions | Vuelve a pedir la vista actual, sin tocar la caché |
Tres trampas comprobadas:
revalidateTagexige ya el segundo argumento. Con uno solo está obsoleto y TypeScript da error.- Nunca se invalida durante el render. Este es el error real:
Route "/e12": `updateTag("x")` can't be called during render. Call it from a
Server Action or Route Handler instead.
- Una etiqueta solo llega a quien la declaró. En el ejemplo,
obtenerDepartamentodeclara tantodepartamentoscomodepartamento-10, para invalidar uno o todos.
cache de React frente a use cache
Se parecen en el nombre y hacen cosas distintas:
cache(fn) de React | 'use cache' de Next | |
|---|---|---|
| Disponible desde | React 19.0 | Next 16 con cacheComponents |
| Dura | Una petición | Lo que diga cacheLife |
| Se comparte entre usuarios | No | Sí |
| Para qué sirve | Que varios componentes de una misma petición no repitan una consulta | Reutilizar resultados entre peticiones |
Reglas de configuración
Con cacheComponents activo, la configuración por segmento ya no vale y el build la rechaza:
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
Route segment config "runtime" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
Cache Components exige el runtime de Node.js. No puedes usar runtime = 'edge' en estas rutas.
Otro efecto: con cacheComponents, Next usa <Activity> (módulo 17) al navegar. La ruta anterior no se desmonta: queda oculta, conserva su estado y sus efectos se limpian. Si un formulario “recuerda” lo escrito al volver, esa es la causa.
El modelo anterior
Si cacheComponents no está en next.config.ts, Next 16.4 funciona como antes. Lo encontrarás en muchos proyectos. La unidad es la ruta y Next decide si es estática o dinámica. Esta tabla sale de compilar y ejecutar un proyecto con Next 16.4.3 sin la opción:
| Ruta | Código | En next build | ¿Qué hace en cada visita? |
|---|---|---|---|
/fetch-simple | await fetch(url) | ○ | Hora idéntica: congelada en la compilación |
/forzar-cache | fetch(url, { cache: 'force-cache' }) | ○ | Hora idéntica: guardada hasta nueva compilación |
/revalidar | fetch(url, { next: { revalidate: 5 } }) | ○ 5 s | Sirve la copia y la regenera al pasar 5 s |
/segmento | export const revalidate = 5 | ○ 5 s | Igual que la anterior, para toda la ruta |
/sin-cache | fetch(url, { cache: 'no-store' }) | ƒ | Hora nueva en cada petición |
/forzar-dinamica | export const dynamic = 'force-dynamic' | ƒ | Hora nueva en cada petición |
/con-cookies | Usa cookies() | ƒ | Una sola línea vuelve dinámica toda la ruta |
⚠️ Importante: Mira la primera fila. La documentación dice, con razón, que
fetchno se cachea por defecto. Pero la ruta sí se prerenderiza si nada la vuelve dinámica, y los datos quedan congelados en el HTML. Es el motivo por el que tantos alumnos creen haber descubierto un fallo: cambian un dato y la página de producción no se entera. Con Cache Components esa situación da error de compilación.
Y sin la opción, 'use cache' no funciona:
Error: To use "use cache", please enable the feature flag `cacheComponents`
in your Next.js config.
| Modelo anterior | Cache Components |
|---|---|
fetch(url, { cache: 'force-cache' }) | Función con 'use cache' |
fetch(url, { next: { revalidate: 60 } }) | cacheLife({ revalidate: 60 }) |
fetch(url, { next: { tags: ['x'] } }) | cacheTag('x') |
unstable_cache(fn) | Función con 'use cache' |
export const dynamic = 'force-dynamic' | Nada: ya es dinámico. Usa <Suspense> |
export const revalidate = 60 | cacheLife en la página o en sus datos |
export const runtime = 'edge' | Sin equivalente: quítalo |
experimental.ppr, experimental.dynamicIO, experimental.useCache | cacheComponents: true |
12. proxy, antes middleware
proxy.ts es un fichero en la raíz del proyecto, junto a app/, que se ejecuta antes de que se resuelva la ruta. Sirve para redirigir, reescribir la URL, o añadir cabeceras y cookies.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (!request.cookies.has('sesion')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/admin/:path*'],
}
Sin cookie, /admin responde con un 307 y Location: /login. Es un filtro rápido: ahorra renderizar nada y se aplica antes de cualquier componente.
De middleware a proxy
Next 16 cambió el nombre y dejó el anterior como obsoleto. Conviene conocer los dos, porque casi todo el material publicado usa middleware. Con Next 16.4.3:
| Situación | Resultado comprobado |
|---|---|
Solo middleware.ts | Funciona y avisa: The "middleware" file convention is deprecated. Please use "proxy" instead. |
Solo proxy.ts | Es lo recomendado |
| Los dos ficheros | El build falla: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected. |
Para migrar hay un codemod, aparte de que el cambio manual es renombrar el fichero y la función exportada:
npx @next/codemod@canary middleware-to-proxy .
Además, proxy se ejecuta en el runtime de Node.js. La opción runtime no está disponible en este fichero y usarla da error.
⚠️ Importante:
proxyno es tu capa de seguridad. Comprueba que hay una cookie, no que sea válida, y no sabe nada de roles. Es una comprobación optimista para redirigir pronto. La comprobación real, con la cookie validada contra la API, debe estar donde se leen o cambian los datos. Por esoZonaPrivadallama ausuarioActualy por eso cada Server Function repite la comprobación.
La zona de administración completa, en la que proxy es solo la primera puerta:
// app/admin/page.tsx
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { obtenerDepartamentos } from '@/lib/api'
import { usuarioActual } from '@/lib/sesion'
import { cerrarSesion } from '../login/acciones'
import { borrarDepartamento } from './acciones'
async function ZonaPrivada() {
const usuario = await usuarioActual()
if (!usuario) redirect('/login')
const departamentos = await obtenerDepartamentos()
return (
<div className="tarjeta">
<h2>Hola, {usuario.nombre}</h2>
<p>Tu rol es {usuario.rol}</p>
<ul>
{departamentos.map((d) => (
<li key={d.deptId}>
<form action={borrarDepartamento} className="fila">
<input type="hidden" name="id" value={d.deptId} />
{d.nombre} <button>Borrar</button>
</form>
</li>
))}
</ul>
<form action={cerrarSesion}>
<button>Cerrar sesión</button>
</form>
</div>
)
}
export default function PaginaAdmin() {
return (
<>
<h1>Administración</h1>
<Suspense fallback={<p className="tarjeta carga">Comprobando la sesión...</p>}>
<ZonaPrivada />
</Suspense>
</>
)
}
Aquí se ve bien cómo el modelo encaja: el usuario se lee por petición, en Suspense, y la lista de departamentos sigue viniendo de la caché compartida, sin que una cosa estropee la otra.
13. La lección de diciembre de 2025
Server Components hizo algo nuevo: convirtió a React en una pieza que recibe peticiones en el servidor. Una SPA solo se ejecuta en el navegador de cada usuario, de modo que un fallo de React queda ahí. Un servidor RSC está expuesto a internet, y un fallo suyo afecta al servidor. En diciembre de 2025 eso dejó de ser una hipótesis.
Todo lo que sigue procede de los dos avisos oficiales de react.dev: Critical Security Vulnerability in React Server Components y Denial of Service and Source Code Exposure in React Server Components.
Figura 12. Cuatro fechas, y cada aviso trajo nuevos parches.
Qué pasó
El 3 de diciembre de 2025 React publicó CVE-2025-55182, conocida como React2Shell, con la máxima puntuación posible: CVSS 10.0. En palabras del aviso, un atacante sin autenticar podía fabricar una petición HTTP maliciosa hacia cualquier punto de entrada de Server Functions que, al ser deserializada por React, lograba ejecutar código remoto en el servidor.
| CVE | Tipo | Gravedad | Publicado |
|---|---|---|---|
| CVE-2025-55182 | Ejecución remota de código | CVSS 10.0 | 3 dic 2025 |
| CVE-2025-55184 | Denegación de servicio | Alta, 7.5 | 11 dic 2025 |
| CVE-2025-67779 | Denegación de servicio: caso que faltaba en la corrección anterior | Alta, 7.5 | 11 dic 2025 |
| CVE-2025-55183 | Exposición de código fuente | Media, 5.3 | 11 dic 2025 |
| CVE-2026-23864 | Varias denegaciones de servicio | Alta, 7.5 | 26 ene 2026 |
Paquetes afectados: react-server-dom-webpack, react-server-dom-parcel y react-server-dom-turbopack. Frameworks citados: next, react-router, waku, @parcel/rsc, @vitejs/plugin-rsc y rwsdk.
Versiones: la ejecución remota se corrigió en 19.0.1, 19.1.2 y 19.2.1, pero los avisos posteriores obligaron a seguir actualizando. React indica que son seguras 19.0.4, 19.1.5 y 19.2.4. En la fecha de este curso las líneas con parches son 19.0.9, 19.1.10, 19.2.9 y 19.3.1. Para Next, el aviso indicaba actualizar a 16.0.11 en la línea 16.0, a 16.1.5 en la 16.1, y a 14.2.35 en las 13.3 a 14.x, entre otras: consulta la lista completa en el aviso si mantienes otra línea.
Quién estaba afectado, y quién no
El aviso es claro en dos frases. No te afecta si tu código React no usa un servidor, ni si no usas un framework, bundler o plugin que soporte Server Components.
Eso incluye todo lo que has hecho hasta el módulo 22: una SPA con Vite servida como ficheros estáticos no tenía esta superficie de ataque.
Pero presta atención a la otra cara. También según el aviso, aunque tu aplicación no implemente ninguna Server Function, puede ser vulnerable si soporta React Server Components. No hacía falta haber escrito 'use server': bastaba con tener el receptor.
Siete lecciones
1. Una tecnología nueva amplía la superficie de ataque, aunque no la uses. Al elegir un framework con RSC, tu frontend se convierte en un servidor expuesto a internet, con todo lo que eso implica. Elegirlo sin necesidad tiene un coste real.
2. Deserializar datos que controla un atacante es una de las operaciones más peligrosas. El fallo estaba en cómo React interpreta el payload de una llamada a Server Function. Es la versión de React de un problema muy antiguo, el mismo que afecta a la serialización nativa de Java. Cualquier dato que llega por la red es hostil hasta que se demuestre lo contrario.
3. Conoce tus dependencias reales. Aquí conecta el apartado 4. Con Next, el código vulnerable estaba dentro del paquete next, en su copia de React. Por eso la instrucción oficial es actualizar next, y mirar el react de tu package.json podía darte una falsa tranquilidad. Comprueba qué versión de Next llevas con:
npx next --version
npm audit
4. Un parche puede estar incompleto. La primera corrección de la denegación de servicio no bastó. El aviso dice literalmente que, si ya habías actualizado a las versiones intermedias, debías actualizar otra vez. Un incidente grave no se cierra con la primera actualización.
5. Un fallo grave atrae más revisión, y la revisión encuentra más fallos. Esto no significa que el software haya empeorado de repente. Hay que contar con ello y seguir atento durante semanas después del aviso.
6. Las mitigaciones del proveedor no sustituyen al parche. Varias plataformas de hosting desplegaron bloqueos temporales. El texto oficial insiste en que no debes depender de ellos: hay que actualizar igualmente.
7. No escribas secretos en el código. La exposición de código fuente, CVE-2025-55183, podía devolver el código de una Server Function. El aviso aclara que los secretos escritos en el fuente podían quedar expuestos, pero no los de ejecución como process.env.SECRET. Es la razón práctica de una regla que ya conocías:
// Mal: el secreto forma parte del código
const conexion = crearConexion('clave-super-secreta')
// Bien: el secreto vive en el entorno
const conexion = crearConexion(process.env.CLAVE_BD)
Qué hacer en tu proyecto
- Mantén
nexten una versión con parches. En cada proyecto nuevo, activa alertas de dependencias, como Dependabot en GitHub. - Ten un procedimiento para desplegar una corrección el mismo día. Si tu aplicación tarda una semana en llegar a producción, ese es tu problema de seguridad.
- Si durante una vulnerabilidad crítica tu servidor estuvo expuesto sin parchear, actualizar no borra lo ocurrido. Con una ejecución remota de código hay que asumir el peor caso y rotar los secretos.
- Aplica defensa en profundidad: el proceso de Node sin privilegios y solo con los permisos que necesite, para limitar el daño.
14. Cuándo usar Server Components
Después de todo lo anterior, la pregunta sensata: ¿lo necesitas?
| Tu aplicación | Recomendación | Motivo |
|---|---|---|
| Panel interno tras un login | SPA con Vite | SEO irrelevante, y es más simple de desplegar |
| Sitio de contenido, blog, documentación | Next, o incluso un generador estático | SEO y primera carga importan; poca interactividad |
| Tienda con catálogo público | Next | SEO, datos en el servidor, PPR |
| Aplicación con mucho estado en cliente (editor, mapa, juego) | SPA con Vite | Casi todo es Client Component de todas formas |
| Ya tienes una API aparte, en Java o .NET | Las dos valen | Sopesa el coste de un servidor Node más |
| Alojamiento solo estático, como GitHub Pages | SPA con Vite | No hay servidor |
Coste de Next que debes tener en cuenta:
- Necesitas un proceso de Node en ejecución si usas funciones de servidor.
- Más conceptos: dos entornos, frontera, serialización, caché, streaming.
- Más superficie de seguridad, como ha mostrado el apartado anterior.
- El framework cambia a menudo y aprende de sus errores: lo has visto con la caché.
Si te interesa RSC sin Next, React Router 8 (módulo 18) y @vitejs/plugin-rsc también lo soportan. Los conceptos de este módulo te sirven, salvo los que son propios de Next: use cache, proxy, los ficheros reservados.
15. Errores frecuentes
Todos estos mensajes son reales, obtenidos con Next 16.4.3.
| Síntoma | Causa | Solución |
|---|---|---|
You're importing a module that depends on useState into a React Server Component module | Hook en un Server Component | Añade 'use client' o mueve esa parte a un componente hoja |
Functions cannot be passed directly to Client Components | Pasas una función normal como prop | Mueve el manejador al Client Component, o usa una Server Function |
Only plain objects, and a few built-ins, can be passed to Client Components | Pasas una instancia de una clase | Pasa un objeto plano con los campos necesarios |
Cannot use() an already resolved Client Reference. | use(Contexto) en un Server Component | Lee el contexto en un Client Component, o usa props |
is an async Client Component | async junto a 'use client' | Obtén los datos en un Server Component |
Hydration failed because the server rendered text didn't match the client | Valor distinto en servidor y cliente | Muévelo a un efecto |
Only async functions are allowed to be exported in a "use server" file | Exportas una constante | Sácala a otro fichero |
can't be called during render | updateTag en un render | Llámalo en una Server Function |
Route segment config "dynamic" is not compatible | Configuración del modelo anterior | Quítala y usa use cache o Suspense |
Next.js encountered uncached data during prerendering | Dato sin use cache ni Suspense | Elige una de las dos opciones |
Both middleware file... and proxy file... | Sobran ficheros | Quédate con proxy.ts |
Cannot read properties of null durante next build | Tus tipos no coinciden con la API real | Revisa los datos: el prerender ejecuta tu código |
This page couldn't load con un código ERROR | Excepción en producción | Mira el registro del servidor por ese digest; si es esperable, devuélvela como valor |
| La página no cambia aunque cambien los datos | Modelo anterior: ruta prerenderizada | Revisa el símbolo en next build |
process.env.X vale undefined | Lo lees en un Client Component | Pásalo como prop o llama al servidor. No uses NEXT_PUBLIC_ para secretos |
El redirect no redirige | Está dentro de un try que captura todo | Llámalo fuera del try |
fetch failed y Error occurred prerendering page en next build | La API no está en marcha al compilar | Arráncala antes de next build |
16. Ejercicios
Sigue en el proyecto empresa-next, con la API en marcha.
Ejercicio 1: ¿Servidor o cliente?
Para cada componente, di si debe ser Server o Client Component y por qué:
- Una tabla que lista los empleados de la API.
- Un botón “Copiar enlace”, que usa
navigator.clipboard. - Un pie de página con el nombre de la empresa.
- Un campo que filtra una lista mientras escribes, sin ir al servidor.
- Un gráfico de una biblioteca que usa
useEffect. - Un componente que lee una clave de
process.envpara llamar a un servicio de pago.
Ver solución
- Server. Solo pinta datos, y evita enviar código y hacer peticiones desde el cliente.
- Client. Necesita un evento y una API del navegador.
- Server. No tiene ni datos ni interactividad: no aporta nada enviarlo como JavaScript.
- Client. Tiene estado que cambia con cada tecla. Si la búsqueda fuese en el servidor, el campo seguiría siendo cliente, y los resultados, servidor, como en el
Buscador. - Client. Y como la biblioteca no lleva la directiva, necesitas un fichero intermedio con
'use client'. - Server, sin duda. Nunca debe llegar al navegador. Protégelo con
import 'server-only'.
La pregunta útil no es “¿qué es este componente?”, sino “¿necesita algo del navegador?”. Si no, Server.
Ejercicio 2: Ficha de empleado
Crea /empleados/[id] con los datos de un empleado. Si no existe, muestra la página 404. Los identificadores son desconocidos al compilar, y debe compilar con Cache Components.
Ver solución
Añade a lib/api.ts:
export async function obtenerEmpleado(id: number) {
'use cache'
cacheLife('minutes')
cacheTag('empleados', `empleado-${id}`)
try {
return await pedir<Empleado>(`/empleados/${id}`)
} catch (error) {
if (error instanceof ErrorApi && error.estado === 404) return null
throw error
}
}
// app/empleados/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { obtenerEmpleado } from '@/lib/api'
type Props = { params: Promise<{ id: string }> }
async function Ficha({ params }: Props) {
const { id } = await params
const empleado = await obtenerEmpleado(Number(id))
if (!empleado) notFound()
return (
<div className="tarjeta">
<h2>
{empleado.nombre} {empleado.apellidos}
</h2>
<p>
{empleado.oficio} en {empleado.departamento}
</p>
<p>Salario: {empleado.salario.toLocaleString('es-ES')} €</p>
</div>
)
}
export default function PaginaEmpleado({ params }: Props) {
return (
<Suspense fallback={<p className="tarjeta carga">Cargando ficha...</p>}>
<Ficha params={params} />
</Suspense>
)
}
params es una promesa y se lee dentro de Suspense, porque sin generateStaticParams su valor solo se conoce en la petición. La función cacheada no lanza un 404: devuelve null, y quien decide es el componente.
Ejercicio 3: Islas de interactividad
Crea /productos con cinco productos, obtenidos en el servidor con use cache. Cada uno tiene un botón de favorito y un desplegable con la descripción. La descripción debe seguir viniendo del servidor.
Ver solución
Desplegable es el del apartado 7. El botón:
// app/productos/BotonFavorito.tsx
'use client'
import { useState } from 'react'
export function BotonFavorito({ nombre }: { nombre: string }) {
const [favorito, setFavorito] = useState(false)
return (
<button
aria-pressed={favorito}
aria-label={`Favorito: ${nombre}`}
onClick={() => setFavorito(!favorito)}
>
{favorito ? 'En favoritos' : 'Añadir a favoritos'}
</button>
)
}
// app/productos/page.tsx
import { pedir, type Pagina } from '@/lib/api'
import { cacheLife } from 'next/cache'
import { BotonFavorito } from './BotonFavorito'
import { Desplegable } from './Desplegable'
type Producto = {
productoId: number
nombre: string
descripcion: string
precio: number
}
async function obtenerProductos() {
'use cache'
cacheLife('hours')
const datos = await pedir<Pagina<Producto>>('/productos?tamano=5')
return datos.contenido
}
export default async function PaginaProductos() {
const productos = await obtenerProductos()
return (
<>
<h1>Productos</h1>
{productos.map((p) => (
<div className="tarjeta" key={p.productoId}>
<div className="fila">
<strong>{p.nombre}</strong>
<BotonFavorito nombre={p.nombre} />
</div>
<Desplegable titulo="Ver detalle">
<p>{p.descripcion}</p>
<p>{p.precio.toLocaleString('es-ES')} €</p>
</Desplegable>
</div>
))}
</>
)
}

Figura 13. La página es un Server Component con dos pequeños Client Components dentro.
El botón recibe solo nombre, no el producto entero. Y la descripción es children: se resuelve en el servidor aunque se muestre dentro de un Client Component.
Ejercicio 4: Cambiar el estado de un pedido
Crea /pedidos/[id] con un botón por cada estado posible. Usa .bind para pasar el identificador y el estado a la Server Function. La operación exige sesión y debe validar los argumentos.
Ver solución
// app/pedidos/[id]/acciones.ts
'use server'
import { refresh } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { pedir } from '@/lib/api'
import { usuarioActual } from '@/lib/sesion'
const Argumentos = z.tuple([
z.number().int().positive(),
z.enum(['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']),
])
export async function cambiarEstado(id: number, estado: string) {
const usuario = await usuarioActual()
if (!usuario) redirect('/login')
const [pedidoId, nuevoEstado] = Argumentos.parse([id, estado])
await pedir(`/pedidos/${pedidoId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${usuario.token}`,
},
body: JSON.stringify({ estado: nuevoEstado }),
})
refresh()
}
// app/pedidos/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { ErrorApi, pedir } from '@/lib/api'
import { cambiarEstado } from './acciones'
type Pedido = { pedidoId: number; estado: string; cliente: string }
type Props = { params: Promise<{ id: string }> }
const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']
async function obtenerPedido(id: number) {
try {
return await pedir<Pedido>(`/pedidos/${id}`)
} catch (error) {
if (error instanceof ErrorApi && error.estado === 404) return null
throw error
}
}
async function Detalle({ params }: Props) {
const { id } = await params
const pedido = await obtenerPedido(Number(id))
if (!pedido) notFound()
return (
<div className="tarjeta">
<h2>Pedido {pedido.pedidoId}</h2>
<p>Cliente: {pedido.cliente}</p>
<p>Estado: {pedido.estado}</p>
<div className="fila">
{ESTADOS.filter((e) => e !== pedido.estado).map((estado) => (
<form key={estado} action={cambiarEstado.bind(null, pedido.pedidoId, estado)}>
<button>{estado}</button>
</form>
))}
</div>
</div>
)
}
export default function PaginaPedido({ params }: Props) {
return (
<Suspense fallback={<p className="tarjeta carga">Cargando pedido...</p>}>
<Detalle params={params} />
</Suspense>
)
}

Figura 14. Al pulsar, el servidor cambia el estado y vuelve a pintar la vista.
Aunque los valores de .bind los escribe el servidor, los argumentos también se validan: la petición siempre la puede fabricar otro. Como esta lectura no está cacheada, basta con refresh() para volver a pedir la vista.
Ejercicio 5: Frontera de error con reintento
Crea /informe, que llama a una ruta de la API que no existe, y un error.tsx con un botón para reintentar. Muestra el digest, no el mensaje. Comprueba en la prueba de producción qué llega al navegador.
Ver solución
// app/informe/page.tsx
import { Suspense } from 'react'
import { pedir, type Pagina } from '@/lib/api'
import { connection } from 'next/server'
async function Informe() {
await connection()
const { total } = await pedir<Pagina<unknown>>('/ruta-que-no-existe')
return <p>{total}</p>
}
export default function PaginaInforme() {
return (
<>
<h1>Informe</h1>
<Suspense fallback={<p className="tarjeta carga">Generando...</p>}>
<Informe />
</Suspense>
</>
)
}
// app/informe/error.tsx
'use client'
export default function ErrorInforme({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
<div className="tarjeta">
<h2>No se ha podido generar el informe</h2>
<p>Código de seguimiento: {error.digest ?? 'sin código'}</p>
<button onClick={() => retry()}>Reintentar</button>
</div>
)
}

Figura 15. El usuario recibe un código, no el error interno.
Al pulsar Reintentar vuelve a aparecer “Generando…”: se ha repetido la petición al servidor. En la consola del navegador verás además el error de React 419, que indica que el servidor no pudo terminar esa frontera de Suspense.
Fíjate en que la prop se llama retry. Es lo que documenta Next 16.4: vuelve a pedir el segmento al servidor. Muchos ejemplos antiguos usan reset, que solo vuelve a renderizar en el cliente y que no sirve cuando el fallo está en un Server Component.
Ejercicio 6: Catálogo con clave de caché
Crea /catalogo?categoria=2, con enlaces por categoría. El listado debe estar cacheado por categoría, y debe mostrar a qué hora se generó, para que veas que cada categoría tiene su entrada.
Ver solución
// lib/catalogo.ts
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { pedir, type Pagina } from './api'
type Categoria = { categoriaId: number; nombre: string }
type Producto = { productoId: number; nombre: string }
export async function obtenerCategorias() {
'use cache'
cacheLife('days')
cacheTag('categorias')
const datos = await pedir<Pagina<Categoria>>('/categorias?tamano=100')
return datos.contenido
}
export async function obtenerCatalogo(categoriaId: number | null) {
'use cache'
cacheLife('minutes')
cacheTag('catalogo', `catalogo-${categoriaId ?? 'todo'}`)
const filtro = categoriaId ? `&categoriaId=${categoriaId}` : ''
const datos = await pedir<Pagina<Producto>>(`/productos?tamano=100${filtro}`)
return { productos: datos.contenido, generado: new Date().toISOString() }
}
// app/catalogo/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { obtenerCatalogo, obtenerCategorias } from '@/lib/catalogo'
type Props = { searchParams: Promise<{ categoria?: string }> }
async function Listado({ searchParams }: Props) {
const { categoria } = await searchParams
const { productos, generado } = await obtenerCatalogo(Number(categoria) || null)
return (
<div className="tarjeta">
<h2>{productos.length} productos</h2>
<p>Generado a las {generado}</p>
<ul>
{productos.map((p) => (
<li key={p.productoId}>{p.nombre}</li>
))}
</ul>
</div>
)
}
export default async function PaginaCatalogo({ searchParams }: Props) {
const categorias = await obtenerCategorias()
return (
<>
<h1>Catálogo</h1>
<div className="fila">
<Link href="/catalogo">Todas</Link>
{categorias.map((c) => (
<Link key={c.categoriaId} href={`/catalogo?categoria=${c.categoriaId}`}>
{c.nombre}
</Link>
))}
</div>
<Suspense fallback={<p className="tarjeta carga">Cargando catálogo...</p>}>
<Listado searchParams={searchParams} />
</Suspense>
</>
)
}
Esto es lo medido al visitar ?categoria=2 dos veces, y luego ?categoria=1:
?categoria=2 3 productos Generado a las 2026-11-02T06:57:31.627Z
?categoria=2 3 productos Generado a las 2026-11-02T06:57:31.627Z
?categoria=1 1 productos Generado a las 2026-11-02T06:57:33.580Z
La segunda visita reutiliza la entrada; otra categoría genera una nueva.
searchParams no entra en la función cacheada: se lee fuera y solo se pasa el valor que importa. Si pasaras todos los parámetros de la URL, cualquier parámetro extraño crearía una entrada nueva. Number(categoria) || null hace que una categoría mal escrita caiga en el listado general.
Ejercicio 7: Usuario en Context desde el servidor
Crea /perfil. Un Server Component lee la sesión y la entrega por <Context> a un Client Component que saluda al usuario, sin envoltorio cliente, y sin enviar el token al navegador.
Ver solución
// app/perfil/usuario.ts
'use client'
import { createContext } from 'react'
export type DatosUsuario = { nombre: string; rol: string } | null
export const UsuarioContexto = createContext<DatosUsuario>(null)
// app/perfil/Saludo.tsx
'use client'
import { use } from 'react'
import { UsuarioContexto } from './usuario'
export function Saludo() {
const usuario = use(UsuarioContexto)
return <p>{usuario ? `Hola, ${usuario.nombre}` : 'Hola, visitante'}</p>
}
// app/perfil/page.tsx
import { Suspense } from 'react'
import { usuarioActual } from '@/lib/sesion'
import { Saludo } from './Saludo'
import { UsuarioContexto } from './usuario'
async function ConUsuario({ children }: { children: React.ReactNode }) {
const usuario = await usuarioActual()
const datos = usuario && { nombre: usuario.nombre, rol: usuario.rol }
return <UsuarioContexto value={datos}>{children}</UsuarioContexto>
}
export default function PaginaPerfil() {
return (
<div className="tarjeta">
<Suspense fallback={<p className="carga">Comprobando sesión...</p>}>
<ConUsuario>
<Saludo />
</ConUsuario>
</Suspense>
</div>
)
}
La clave está en datos: se copian solo los campos públicos. Si pasaras usuario directamente, el token iría en el payload, y la cookie httpOnly no habría servido de nada. Este patrón necesita <Context> en servidor, por lo que depende de la copia de React que traiga tu Next.
Ejercicio 8: Borrado con error de negocio
El borrado del apartado 12 rompe la página con un 409. Conviértelo: si el departamento tiene empleados, muestra el motivo junto al botón. Cualquier otro fallo debe seguir escapando.
Ver solución
Añade a app/admin/acciones.ts, importando también ErrorApi:
export async function borrarDepartamentoSeguro(
id: number,
_anterior: string | null,
_datos: FormData,
): Promise<string | null> {
const usuario = await exigirAdmin()
try {
await pedir(`/departamentos/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${usuario.token}` },
})
} catch (error) {
if (error instanceof ErrorApi && error.estado === 409) return error.message
throw error
}
updateTag('departamentos')
return null
}
// app/admin/BotonBorrar.tsx
'use client'
import { useActionState } from 'react'
import { borrarDepartamentoSeguro } from './acciones'
export function BotonBorrar({ id, nombre }: { id: number; nombre: string }) {
const [error, accion, pendiente] = useActionState(
borrarDepartamentoSeguro.bind(null, id),
null,
)
return (
<form action={accion} className="fila">
{nombre} <button disabled={pendiente}>Borrar</button>
{error && <span className="error">{error}</span>}
</form>
)
}
En ZonaPrivada, sustituye el <form> de cada fila por <BotonBorrar id={d.deptId} nombre={d.nombre} />.

Figura 16. El error esperado es ya un valor, y la página sigue viva.
El orden de los parámetros importa: lo que aporta .bind va primero, y luego lo que añade useActionState, que es el estado anterior y el FormData.
Ejercicio 9: Revisión de seguridad
Encuentra al menos cinco problemas en este código:
// app/empleados/[id]/acciones.ts
'use server'
const CLAVE = 'clave-nomina-2026'
export async function subirSalario(id: string, salario: number) {
await fetch(`${process.env.NEXT_PUBLIC_API_NOMINAS}/${id}`, {
method: 'PUT',
headers: { 'X-Clave': CLAVE },
body: JSON.stringify({ salario }),
})
}
// Server Component
const empleado = await obtenerEmpleado(id)
return <BotonSubir empleado={empleado} />
Ver solución
- Sin autenticación. Cualquiera puede llamarla: es un punto de entrada público.
- Sin autorización. Aun con sesión, debe comprobar que el rol permite cambiar nóminas.
- Sin validación.
salariopuede ser negativo o no ser un número, eidpuede contener../y alterar la URL. TypeScript no actúa en ejecución. - Secreto en el código.
CLAVEpudo quedar expuesta con CVE-2025-55183. Debe ir enprocess.env.CLAVE_NOMINA. NEXT_PUBLIC_innecesario. Revela al navegador la dirección de un servicio interno. Usa una variable sin prefijo.- Sobreexposición de datos.
BotonSubirrecibe el empleado entero, con su salario y su correo, y todo acaba en el payload. Pasa solo elid. - Sin invalidación ni control de errores. Falta
updateTagy no se compruebarespuesta.ok.
Ejercicio 10 (propuesto): Migrar al modelo nuevo
Crea un proyecto sin cacheComponents, con una página que use fetch(url, { next: { revalidate: 60, tags: ['x'] } }) y otra con export const dynamic = 'force-dynamic'. Actívalo y arregla cada error del build usando la tabla del apartado 11. Anota cuántas rutas cambian de símbolo.
Ejercicio 11 (propuesto): Cuánto pesa cada frontera
Añade 'use client' a app/departamentos/page.tsx. Tendrás que cambiar varias cosas para que compile. Compara el tamaño de .next/static antes y después, y mira qué pasa con el acceso a la API. Escribe qué concluyes.
Ejercicio 12 (propuesto): Escaparate con PPR
Crea una portada con un texto estático, unos productos destacados cacheados con cacheLife('hours'), y una tarjeta “Hola, nombre” que dependa de la sesión. Comprueba con curl que la cabecera x-nextjs-prerender aparece, y lee el orden de lo que llega en el cuerpo de la respuesta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Estrategias | CSR, SSR, SSG, ISR, PPR | Lo que cambia es cuándo y dónde se genera el HTML |
| Hidratación | hydrateRoot | Enganchar eventos al HTML; ambos renders deben coincidir |
| Server Component 🆕 19.0 | Valor por defecto en el App Router | Solo se ejecuta en el servidor. No es SSR |
| Payload RSC | Cabecera RSC: 1 | Árbol serializado con referencias a módulos cliente |
| Copia de React | next/dist/compiled/react | El App Router usa la canary de Next, no tu package.json |
| App Router | page, layout, loading, error, not-found | Las carpetas son URL; los ficheros, papeles |
| Parámetros | params, searchParams | Son promesas: siempre con await |
| Frontera | 'use client' | Marca módulos, no componentes. Lo más abajo posible |
| Composición | children | Un Client Component puede recibir contenido del servidor |
| Context | <Context> 🆕 19.3 | Se proporciona desde el servidor; no se crea ni se lee ahí |
| Server Function 🆕 19.0 | 'use server' | Es un punto de entrada público: autoriza y valida dentro |
| Errores | Devolver o lanzar | Lo esperado se devuelve; lo inesperado llega a error.tsx |
| Streaming | <Suspense>, loading.tsx | El armazón primero, y cada parte cuando esté lista |
| Promesa al cliente 🆕 19.0 | use(promesa) | Sin await en el servidor, para no bloquear |
| Caché | cacheComponents: true | Nada se cachea si no lo pides; proyectos nuevos, activado |
| Guardar | 'use cache', cacheLife | Los argumentos forman parte de la clave |
| Invalidar | updateTag, revalidateTag(t, 'max'), refresh | Etiquetas al guardar, invalidación al cambiar |
| Modelo anterior | fetch con opciones, dynamic, revalidate | Sin la opción, y la ruta se congela sin avisar |
| Filtro previo | proxy.ts (antes middleware.ts) | Redirige pronto, pero no es tu capa de seguridad |
| Seguridad | CVE-2025-55182 y siguientes | Actualiza next, sin secretos en el código, y sigue los avisos |