El módulo anterior terminó señalando una frontera: CicloUrbano es una aplicación que se descarga al navegador y se ejecuta allí. Esa decisión, que en el módulo 1 parecía puramente técnica, tiene consecuencias que no se arreglan con memo, ni con división de código, ni con pruebas. El primer pintado espera al JavaScript; los buscadores y las redes sociales ven una página vacía; y en una conexión lenta el usuario mira un fondo blanco durante segundos. Esta lección cruza esa frontera. Vas a entender qué hace exactamente el navegador cuando recibe una aplicación de una sola página, cuáles son las cuatro estrategias de renderizado que existen y cuándo conviene cada una, qué es la hidratación y por qué es el punto donde el SSR se rompe, y cómo se construye todo eso con Next.js 15 y su App Router. Al final tendrás en marcha ciclourbano-web, el escaparate público de CicloUrbano renderizado en el servidor, conviviendo con la aplicación de gestión que llevas nueve módulos construyendo.
Contenido
- Lo que el navegador recibe hoy: un
<div>vacío - Qué implica para el primer pintado y para las conexiones lentas
- Lo que ven los buscadores y las tarjetas de previsualización
- Las cuatro estrategias de renderizado
- Qué es la hidratación
- Discrepancias entre servidor y cliente
- Por qué hace falta un framework
- Next.js 15: crear
ciclourbano-web - Rutas por carpetas:
layout,page,loading,error,not-found - Del mapa de React Router al mapa de Next.js
- Componentes de servidor: datos con
async/await - La directiva
'use client', en cuatro líneas - Renderizado dinámico: cuándo Next.js decide renderizar en cada petición
- Metadatos y SEO
- Sesión sin
localStorage - Streaming: el HTML por partes
- Lo que el navegador recibe hoy: un
<div> vacío
<div> vacíoAbre index.html del proyecto Vite de CicloUrbano y mira lo que se envía realmente al navegador. No es lo que ves en pantalla: es esto.
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CicloUrbano</title>
<script type="module" crossorigin src="/assets/index-a3f9c2.js"></script>
<link rel="stylesheet" href="/assets/index-7b21e4.css" />
</head>
<body>
<div id="root"></div>
</body>
</html>Ese es todo el HTML de CicloUrbano. Un contenedor vacío y una etiqueta <script>. Todo lo demás —la cabecera, el catálogo, las cinco tarjetas de bicicleta, el pie de página— lo construye JavaScript en el navegador después.
Puedes comprobarlo tú mismo sin herramientas especiales. En el navegador, «Ver código fuente de la página» (Ctrl+U) muestra el HTML tal y como llegó del servidor, antes de que se ejecute nada. El inspector de elementos, en cambio, muestra el DOM ya construido. La diferencia entre esas dos vistas es exactamente la diferencia entre lo que el servidor envía y lo que React fabrica.
La secuencia real, paso a paso, cuando alguien abre la ficha de bici-002:
sequenceDiagram
participant N as Navegador
participant S as Servidor estatico
participant A as API (json-server)
N->>S: GET /bicicletas/bici-002
S-->>N: HTML con div#root vacio
Note over N: Pantalla en blanco
N->>S: GET /assets/index.js (270 KB gzip)
S-->>N: JavaScript
Note over N: Analizar + ejecutar
Note over N: React monta · primer render
N->>A: GET /bicicletas/bici-002
A-->>N: JSON
Note over N: Segundo render · contenido visible
Hay tres viajes de ida y vuelta encadenados antes de que aparezca el modelo de la bicicleta: el HTML, el JavaScript y los datos. Y son secuenciales: el navegador no puede pedir los datos hasta que no ha ejecutado el JavaScript, y no puede ejecutar el JavaScript hasta que no ha recibido el HTML que lo referencia.
- Qué implica para el primer pintado y para las conexiones lentas
Traducido a las métricas que se usan para medir la experiencia real:
| Métrica | Qué mide | En una SPA pura |
|---|---|---|
| TTFB (Time To First Byte) | Cuánto tarda el primer byte del HTML | Muy bueno: el HTML es diminuto y estático |
| FCP (First Contentful Paint) | Cuándo aparece el primer contenido | Malo: espera a descargar y ejecutar el JS |
| LCP (Largest Contentful Paint) | Cuándo aparece el elemento principal | Muy malo: espera además a los datos de la API |
| TTI (Time To Interactive) | Cuándo responde a un clic | Coincide más o menos con el LCP |
El TTFB engaña: es excelente, pero lo que llega en ese primer byte no sirve de nada. Lo que el usuario percibe es el FCP y el LCP, y ambos están al final de una cadena de esperas.
Ahora añade la variable que se olvida cuando se desarrolla en un portátil con fibra: no todo el mundo tiene tu conexión ni tu móvil. En el módulo 8 ya vimos que analizar y ejecutar JavaScript en un móvil de gama media va entre 3 y 5 veces más lento. Sobre una red 4G con congestión, los 270 KB comprimidos tardan más de un segundo solo en llegar.
Y hay un caso peor que el lento: el que falla. Si el fichero de JavaScript no se descarga —red inestable, un bloqueador demasiado agresivo, un error de despliegue en el CDN—, el usuario no ve una versión degradada de CicloUrbano. Ve una página en blanco. Con HTML renderizado en el servidor, ese mismo usuario ve el contenido; lo que pierde es la interactividad, que es una degradación mucho más razonable.
Idea clave. Una SPA hace que el contenido dependa del código. El renderizado en servidor rompe esa dependencia: el contenido llega como contenido, y el código solo añade interactividad encima.
- Lo que ven los buscadores y las tarjetas de previsualización
Este es el argumento que suele decidir la migración, porque es dinero.
Imagina la ficha pública de bici-002, la Eléctrica Pro de Plaza Mayor. En la SPA, el HTML que llega es el <div id="root"> vacío y un <title>CicloUrbano</title> genérico. Todo lo demás —el nombre del modelo, el precio de 4,00 €/h, el barrio— aparece después, en el DOM.
Qué ocurre con cada consumidor de ese HTML:
| Consumidor | ¿Ejecuta JavaScript? | Qué ve de bici-002 |
|---|---|---|
| Googlebot | Sí, pero en una segunda pasada diferida | Puede acabar indexándolo, con retraso y sin garantías |
| Otros buscadores | A menudo no, o parcialmente | Una página vacía |
| WhatsApp, Slack, Telegram | No | Título genérico, sin descripción ni imagen |
| Twitter/X, LinkedIn, Facebook | No | Lo mismo |
| Lectores de RSS, agregadores, herramientas de SEO | Casi nunca | Nada |
El caso de las tarjetas de previsualización es el más claro y el más fácil de comprobar. Cuando alguien pega https://ciclourbano.test/bicicletas/bici-002 en un chat, la aplicación de mensajería hace una petición HTTP simple y busca en el HTML las etiquetas <meta property="og:title">, og:description y og:image. No ejecuta JavaScript, ni ahora ni nunca. Si esas etiquetas se ponen desde React con useEffect, la aplicación de mensajería jamás las verá.
Con la SPA, la previsualización de cualquier bicicleta del catálogo es idéntica:
Con SSR, cada una es la suya:
Eléctrica Pro · Plaza Mayor — CicloUrbano Bicicleta eléctrica disponible en la estación Plaza Mayor (Centro). 4,00 €/h. Reserva por horas desde la web. [imagen de la bicicleta]
La segunda se comparte; la primera no. Y ese es un problema de negocio, no de rendimiento.
- Las cuatro estrategias de renderizado
Esta tabla es la columna vertebral del módulo. Todo lo que viene en 10-01 y 10-02 son notas al pie de estas cuatro filas.
| CSR | SSR | SSG | ISR | |
|---|---|---|---|---|
| Nombre | Renderizado en cliente | Renderizado en servidor | Generación estática | Regeneración incremental |
| Cuándo se genera el HTML | En el navegador, tras cargar el JS | En cada petición | Una vez, en next build |
En la construcción y luego, cada N segundos, en la primera petición tras caducar |
| Quién lo genera | El navegador del usuario | El servidor Node | La máquina de construcción | El servidor, en segundo plano |
| Qué se ve primero | Un div vacío |
El HTML completo | El HTML completo | El HTML completo |
| Cuándo se queda obsoleto | Nunca (siempre es fresco) | Nunca | En cuanto cambian los datos | Como mucho, N segundos |
| Coste por visita | Ninguno en el servidor | Un render de servidor | Servir un fichero | Servir un fichero (más un render ocasional) |
| Datos personalizados | Sí | Sí | No | No |
| Contenido idóneo | Paneles tras identificación | Catálogo con disponibilidad en vivo, resultados de búsqueda | Páginas informativas, condiciones del servicio | Fichas de bicicleta, listado de estaciones |
Aplicado a CicloUrbano, la lectura rápida es:
- El panel de taller (
/taller) es privado, personalizado por rol y no lo indexa nadie: CSR está bien. - El catálogo público con disponibilidad en tiempo real cambia cada pocos minutos y debe ser indexable: SSR.
- La página de condiciones del servicio cambia dos veces al año: SSG.
- La ficha de una bicicleta cambia poco pero cambia, y son cientos: ISR.
En esta lección construimos la columna del SSR. Las dos de la derecha son la lección siguiente.
Una precisión importante para no confundirse desde el principio: estas cuatro estrategias no son marcos alternativos entre los que hay que elegir uno para todo el proyecto. En Next.js con App Router se eligen ruta por ruta, e incluso dentro de una misma ruta se puede mezclar una parte estática con una isla dinámica. Ese es precisamente el motivo de que un framework aporte algo.
- Qué es la hidratación
Con SSR, el servidor genera el HTML del catálogo y lo envía. El navegador lo pinta de inmediato: el usuario ve las cinco tarjetas de bicicleta a los 400 ms. Pero ese HTML es inerte. Los botones no responden, el SelectorTipo no filtra, no hay estado, no hay manejadores.
La hidratación es el proceso por el que React, ya en el navegador, recorre ese HTML existente y le «da vida»: monta el árbol de componentes, crea el estado y conecta los manejadores de eventos a los nodos del DOM que ya están ahí, sin volver a crearlos.
flowchart TB
subgraph SERVIDOR
A["React renderiza el arbol"] --> B["HTML como texto"]
end
B --> C["El navegador pinta el HTML<br/>Contenido VISIBLE pero inerte"]
C --> D["Llega el bundle de JavaScript"]
D --> E["hydrateRoot: React recorre el DOM existente"]
E --> F["Asocia componentes a nodos<br/>y engancha manejadores"]
F --> G["Aplicacion INTERACTIVA"]
Es útil contrastarlo con lo que ya conoces:
createRoot(...).render(...) |
hydrateRoot(...) |
|
|---|---|---|
| Punto de partida | Un contenedor vacío | HTML ya generado por el servidor |
| Qué hace con el DOM | Lo crea entero | Lo reutiliza y solo lo asocia |
| Si no coincide con lo esperado | No aplica | Avisa y, en el peor caso, lo descarta y lo vuelve a crear |
La palabra clave es reutiliza. React parte de la premisa de que el HTML del servidor es exactamente el que él mismo produciría en el primer render del cliente. Si esa premisa se rompe, aparece el problema del apartado siguiente.
Y una consecuencia que conviene interiorizar desde ya: el SSR no elimina el JavaScript. El paquete se sigue descargando y ejecutando. Lo que el SSR hace es desacoplar el momento en que se ve el contenido del momento en que la aplicación es interactiva. Entre el FCP y el TTI hay ahora una ventana en la que la página se ve pero no responde: es lo que se llama el «valle inquietante» de la hidratación. Reducirlo es justamente lo que persiguen los React Server Components de 10-03.
- Discrepancias entre servidor y cliente
Un error de hidratación (hydration mismatch) ocurre cuando el HTML que React genera en el cliente durante el primer render no coincide con el que llegó del servidor. En React 19 el mensaje es explícito y muestra un diferencial:
Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.
<TarjetaBicicleta>
<span className="fecha">
- Reservada hasta las 18:42 ← servidor
+ Reservada hasta las 20:42 ← clienteLas causas casi siempre son la misma familia de problemas: algo que vale distinto en el servidor y en el navegador.
| Causa | Por qué falla | Solución |
|---|---|---|
new Date(), toLocaleTimeString() |
El servidor está en UTC y el navegador en la zona del usuario | Renderizar la fecha en ISO y formatearla en un efecto, o fijar la zona horaria explícitamente |
Math.random(), crypto.randomUUID() |
Dos ejecuciones, dos valores | Generar el valor una vez en el servidor y pasarlo como prop; para identificadores de accesibilidad, useId |
localStorage, sessionStorage |
No existen en el servidor | Leerlos en useEffect, nunca durante el render |
window, document, navigator |
No existen en el servidor | Comprobar typeof window !== 'undefined' o leerlos en useEffect |
HTML inválido (<div> dentro de <p>) |
El navegador corrige el marcado y ya no coincide | Corregir el marcado |
| Extensiones del navegador | Inyectan atributos antes de la hidratación | No es culpa tuya; suppressHydrationWarning en el nodo afectado |
El caso más frecuente en CicloUrbano es el tema oscuro. El BotonTema lee la preferencia guardada en localStorage, y ese código se ejecuta durante el render:
// INCORRECTO en SSR: localStorage no existe en el servidor
function ProveedorTema({ children }) {
const [tema, setTema] = useState(localStorage.getItem('tema') ?? 'claro');
// ...
}En el servidor esto ni siquiera llega a discrepar: revienta, porque localStorage no está definido. El patrón correcto separa el valor inicial seguro del valor real:
// src/contextos/ProveedorTema.jsx
'use client';
import { createContext, useState, useEffect } from 'react';
export const ContextoTema = createContext(null);
function ProveedorTema({ children }) {
// 1. Valor inicial DETERMINISTA: igual en servidor y en cliente.
const [tema, setTema] = useState('claro');
// 2. Tras la hidratación, y solo entonces, se lee la preferencia real.
useEffect(() => {
const guardado = window.localStorage.getItem('tema');
if (guardado) setTema(guardado);
}, []);
useEffect(() => {
document.documentElement.dataset.tema = tema;
window.localStorage.setItem('tema', tema);
}, [tema]);
return (
<ContextoTema.Provider value={{ tema, setTema }}>
{children}
</ContextoTema.Provider>
);
}
export default ProveedorTema;Qué hace cada pieza:
useState('claro')da un valor determinista: el servidor y el primer render del cliente coinciden, así que la hidratación es limpia.- El primer
useEffectse ejecuta después de la hidratación, ya en el navegador, dondewindowexiste. Si la preferencia guardada era'oscuro', provoca un segundo render y el tema cambia. - El precio de esta corrección es un parpadeo: durante unos milisegundos se ve el tema claro. La solución habitual —fuera del alcance de esta lección— es un pequeño script en línea en el
<head>que fijadata-temaantes de que se pinte nada.
Regla práctica. Durante el render, un componente solo puede usar información que el servidor también tiene. Todo lo demás —almacenamiento, tamaño de ventana, zona horaria, aleatoriedad— pertenece a
useEffect.
- Por qué hace falta un framework
React trae la pieza básica: renderToString en react-dom/server convierte un árbol de componentes en una cadena de HTML. Con eso y un servidor Express se puede montar un SSR mínimo en veinte líneas.
Y funciona… hasta que quieres cualquiera de estas cosas:
- Enrutamiento en dos sitios a la vez, en el servidor y en el cliente, con las mismas rutas y sin duplicar la configuración.
- Precargar los datos de la ruta antes de renderizar, y luego serializarlos en el HTML para que el cliente no los vuelva a pedir.
- Empaquetar dos compilaciones distintas, la del servidor y la del cliente, con código compartido y con CSS que llegue en la primera respuesta.
- Dividir el código por ruta y que el servidor sepa qué fragmentos precargar en cada página.
- Cachear el HTML por ruta, revalidarlo, y decidir por ruta si es estático o dinámico.
- Streaming, componentes de servidor, optimización de imágenes, cabeceras,
sitemap.xml…
Cada punto es un proyecto en sí mismo, y la combinación de todos es un framework. Por eso el ecosistema no escribe SSR a mano: usa Next.js, Remix / React Router en modo framework, Astro o TanStack Start. En este curso usamos Next.js, que es el más extendido y el que desarrolla el modelo de componentes de servidor que verás en 10-03.
- Next.js 15: crear
ciclourbano-web
ciclourbano-webAquí conviene ser explícito sobre qué estamos haciendo, porque es fácil perderse:
No vamos a reescribir CicloUrbano. La aplicación de gestión con Vite —catálogo interno, reservas, taller, Redux, TanStack Query, las pruebas del módulo 9— sigue existiendo tal cual. Lo que creamos ahora es un segundo proyecto,
ciclourbano-web, el escaparate público: las pantallas que se comparten, se indexan y las ve gente que no ha iniciado sesión.
El criterio para decidir qué se lleva al escaparate es sencillo y se resume en tres preguntas:
| Pregunta | Si la respuesta es «sí» | Si es «no» |
|---|---|---|
| ¿La ve alguien sin identificarse? | Candidata al escaparate | Se queda en la SPA |
| ¿Quieres que Google la indexe o que se comparta en un chat? | Candidata al escaparate | Se queda en la SPA |
| ¿Es una herramienta de trabajo con mucha interacción y estado? | Se queda en la SPA | Candidata al escaparate |
Con ese filtro, se migran el catálogo público, la ficha de bicicleta, las páginas de estaciones y el contenido informativo. Se quedan en Vite el panel de taller, la gestión de reservas y todo lo que hay detrás de RutaProtegida.
Creación del proyecto:
El asistente pregunta. Estas son las respuestas para este módulo:
✔ Would you like to use TypeScript? … No ✔ Would you like to use ESLint? … Yes ✔ Would you like to use Tailwind CSS? … No ✔ Would you like your code inside a `src/` directory? … Yes ✔ Would you like to use App Router? (recommended) … Yes ✔ Would you like to use Turbopack? … Yes ✔ Would you like to customize the import alias (`@/*` by default)? … No
Decimos «no» a TypeScript porque lo veremos en 10-04 sobre el proyecto Vite, y «no» a Tailwind porque CicloUrbano ya usa CSS Modules, que Next.js soporta de fábrica. App Router es obligatorio para todo lo que sigue.
El servidor arranca en http://localhost:3000. Como la API ficticia sigue siendo json-server en http://localhost:3001, los dos procesos conviven sin tocarse.
La estructura relevante tras la creación:
ciclourbano-web/ ├── src/ │ └── app/ │ ├── layout.js ← plantilla raíz: <html> y <body> │ ├── page.js ← la ruta "/" │ ├── globals.css │ └── favicon.ico ├── public/ ← ficheros servidos tal cual ├── next.config.mjs ├── jsconfig.json └── package.json
Lo primero que sorprende es lo que no hay: ni main.jsx, ni createRoot, ni index.html, ni rutas.jsx. Next.js se encarga del arranque y de las rutas.
Trasladamos las variables de CicloUrbano a src/app/globals.css sin cambiar un valor:
/* src/app/globals.css */
:root {
--color-marca: #12805c;
--color-alquilada: #b45309;
--color-mantenimiento: #9b1c1c;
--color-fondo: #f5f7fa;
--color-superficie: #ffffff;
--color-texto: #1f2933;
--color-borde: #d9e2ec;
}
:root[data-tema='oscuro'] {
--color-fondo: #10151b;
--color-superficie: #1a222c;
--color-texto: #e6edf3;
--color-borde: #2b3542;
}
body {
background: var(--color-fondo);
color: var(--color-texto);
font-family: system-ui, sans-serif;
margin: 0;
}
- Rutas por carpetas:
layout, page, loading, error, not-found
layout, page, loading, error, not-foundEn React Router, una ruta es un objeto en rutas.jsx. En el App Router de Next.js, una ruta es una carpeta, y dentro de ella ciertos nombres de fichero tienen un significado reservado.
| Fichero | Qué es | Equivalente que ya conoces |
|---|---|---|
page.jsx |
El contenido de la ruta. Sin él, la carpeta no es navegable | El element de una ruta |
layout.jsx |
Envoltorio que persiste entre navegaciones hijas | Una ruta padre con <Outlet /> |
loading.jsx |
Se muestra mientras la página carga | El fallback de un <Suspense> |
error.jsx |
Captura errores del subárbol | LimiteDeError de 04-05 |
not-found.jsx |
Respuesta 404 del subárbol | PaginaNoEncontrada |
template.jsx |
Como layout, pero se remonta en cada navegación |
— |
route.js |
Manejador HTTP (no interfaz) | Un endpoint de API |
Las carpetas que no contienen ninguno de esos ficheros no crean rutas: sirven para organizar. Y hay dos convenciones de nombre muy útiles:
[bicicletaId]→ segmento dinámico, equivalente a:bicicletaId.(escaparate)→ grupo de rutas: agrupa para compartir unlayoutsin añadir nada a la URL.
La plantilla raíz es obligatoria y es la única que contiene <html> y <body>:
// src/app/layout.jsx
import './globals.css';
import Cabecera from '@/componentes/Cabecera';
import PieDePagina from '@/componentes/PieDePagina';
export const metadata = {
title: {
default: 'CicloUrbano · Alquiler de bicicletas urbanas',
template: '%s · CicloUrbano',
},
description: 'Alquila bicicletas urbanas, eléctricas y de carga por horas en tu ciudad.',
};
export default function PlantillaRaiz({ children }) {
return (
<html lang="es">
<body>
<Cabecera />
<main>{children}</main>
<PieDePagina />
</body>
</html>
);
}Tres cosas a destacar:
- La prop se llama
children, no<Outlet />. Next.js inyecta ahí la página o la plantilla hija. Es composición pura, como la del módulo 4. metadataes un objeto exportado, no un componente. Eltemplate: '%s · CicloUrbano'hace que cualquier página que exportetitle: 'Estaciones'acabe siendoEstaciones · CicloUrbano.- Este componente es un componente de servidor: se ejecuta en el servidor y no se envía al navegador. Lo veremos en el apartado 11.
- Del mapa de React Router al mapa de Next.js
Esta es la traducción literal del rutas.jsx de CicloUrbano al árbol de carpetas del escaparate. Recuerda que solo migramos la parte pública.
| Ruta (React Router) | Fichero en Next.js | Notas |
|---|---|---|
/ (catálogo, ?tipo=) |
app/page.jsx |
searchParams sustituye a useSearchParams |
/bicicletas/:bicicletaId |
app/bicicletas/[bicicletaId]/page.jsx |
params sustituye a useParams |
/estaciones |
app/estaciones/page.jsx |
|
/estaciones/:estacionId |
app/estaciones/[estacionId]/layout.jsx |
El layout hace de ruta padre con pestañas |
↳ pestaña flota (índice) |
app/estaciones/[estacionId]/page.jsx |
La ruta índice es el page.jsx del propio nivel |
↳ pestaña incidencias |
app/estaciones/[estacionId]/incidencias/page.jsx |
|
* (no encontrada) |
app/not-found.jsx |
Devuelve un 404 real, no un 200 |
errorElement |
app/error.jsx |
Debe ser componente de cliente |
/acceso, /reservas, /taller |
— | Se quedan en la SPA de Vite |
El árbol resultante:
src/app/
├── layout.jsx → Diseno + Cabecera + PieDePagina
├── page.jsx → PaginaCatalogo
├── loading.jsx → EsqueletoPagina
├── error.jsx → LimiteDeError
├── not-found.jsx → PaginaNoEncontrada
├── bicicletas/
│ └── [bicicletaId]/
│ ├── page.jsx → PaginaFichaBicicleta
│ └── not-found.jsx → «Esa bicicleta no existe»
└── estaciones/
├── page.jsx → PaginaEstaciones
└── [estacionId]/
├── layout.jsx → cabecera de la estación + pestañas
├── page.jsx → PestanaFlota (índice)
└── incidencias/
└── page.jsx → PestanaIncidenciasCompáralo con el diagrama de rutas anidadas del módulo 6: es el mismo árbol. Lo que cambia es que en React Router lo declarabas y aquí lo dibujas con carpetas. La navegación también tiene su equivalencia directa:
| React Router | Next.js |
|---|---|
<Link to="/estaciones"> |
<Link href="/estaciones"> (de next/link) |
useNavigate() |
useRouter() de next/navigation (solo en cliente) |
useParams() |
La prop params de page.jsx (o useParams en cliente) |
useSearchParams() |
La prop searchParams de page.jsx |
<Outlet /> |
La prop children de layout.jsx |
loader |
El propio componente async |
Y aquí aparece la primera anidación real, el layout de la estación con sus pestañas:
// src/app/estaciones/[estacionId]/layout.jsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import PestanasEstacion from '@/componentes/PestanasEstacion';
export default async function PlantillaEstacion({ children, params }) {
// En Next.js 15, params es una promesa: hay que esperarla.
const { estacionId } = await params;
const respuesta = await fetch(`http://localhost:3001/estaciones/${estacionId}`);
if (!respuesta.ok) notFound();
const estacion = await respuesta.json();
return (
<section>
<h1>{estacion.nombre}</h1>
<p>{estacion.barrio} · {estacion.plazas} plazas</p>
<PestanasEstacion estacionId={estacionId} />
{children}
</section>
);
}Puntos importantes:
paramses una promesa en Next.js 15. Se espera conawait. Lo mismo ocurre consearchParams,cookies()yheaders(). Es un cambio respecto a versiones anteriores y una fuente habitual de confusión al leer tutoriales antiguos.notFound()interrumpe el render y hace que Next.js sirva elnot-found.jsxmás cercano con un código HTTP 404 de verdad. En la SPA,PaginaNoEncontradase pintaba con un 200; los buscadores no distinguían una ficha borrada de una válida.- La cabecera de la estación se renderiza en el
layout, así que no se vuelve a pedir al cambiar de pestaña: exactamente el comportamiento que buscábamos con las rutas anidadas del módulo 6.
- Componentes de servidor: datos con
async/await
async/awaitEste es el cambio conceptual más grande de la lección. En el App Router, todos los componentes son componentes de servidor por defecto, y un componente de servidor puede ser async.
Compara. Así pedía CicloUrbano el catálogo con TanStack Query:
// SPA con Vite — src/paginas/PaginaCatalogo.jsx
import { useQuery } from '@tanstack/react-query';
import ListaBicicletas from '../componentes/ListaBicicletas';
import IndicadorDeCarga from '../componentes/IndicadorDeCarga';
function PaginaCatalogo() {
const { data: bicicletas, isPending, isError } = useQuery({
queryKey: ['bicicletas'],
queryFn: () => fetch('http://localhost:3001/bicicletas').then((r) => r.json()),
});
if (isPending) return <IndicadorDeCarga />;
if (isError) return <p>No se ha podido cargar el catálogo.</p>;
return <ListaBicicletas bicicletas={bicicletas} />;
}Y así en Next.js:
// src/app/page.jsx — componente de SERVIDOR
import ListaBicicletas from '@/componentes/ListaBicicletas';
export default async function PaginaCatalogo() {
const respuesta = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store', // disponibilidad en vivo: nunca cachear
});
if (!respuesta.ok) {
throw new Error('No se ha podido cargar el catálogo.');
}
const bicicletas = await respuesta.json();
return <ListaBicicletas bicicletas={bicicletas} />;
}Lo que ha desaparecido, y por qué:
| Desaparece | Por qué |
|---|---|
useQuery y toda la caché de cliente |
Los datos se resuelven antes de renderizar; no hay estado que sincronizar |
isPending y <IndicadorDeCarga /> |
La carga la gestiona loading.jsx a nivel de ruta |
isError y el mensaje de error |
Un throw sube al error.jsx más cercano |
El useEffect de peticiones |
No hay ciclo de vida: la función se ejecuta una vez, en el servidor |
| La cascada petición→render→petición | El servidor ya tiene los datos cuando renderiza |
Hay un detalle que merece atención especial: fetch('http://localhost:3001/...') se ejecuta en el servidor Node, no en el navegador. Eso tiene tres consecuencias inmediatas:
- No hay CORS. Es una petición de servidor a servidor.
- La URL de la API puede ser privada. El navegador nunca la ve. En producción,
http://api.interna:3001funcionaría perfectamente. - Se pueden usar secretos. Una clave de API en
process.env.CLAVE_APIno llega jamás al cliente. Esto es una ventaja de seguridad enorme, y también un riesgo si te equivocas de lado de la frontera (lo veremos en 10-03).
Y lo que sube al navegador de este componente es nada. PaginaCatalogo no forma parte del paquete de JavaScript del cliente: solo viaja su resultado. Ese es el fondo del asunto que 10-03 desarrolla.
Un apunte sobre peticiones múltiples. Si la ficha necesita la bicicleta y su estación, no las encadenes si no dependen una de otra:
// Secuencial: 120 ms + 120 ms = 240 ms — MAL si son independientes
const bicicleta = await obtenerBicicleta(bicicletaId);
const estaciones = await obtenerEstaciones();
// En paralelo: max(120, 120) = 120 ms — BIEN
const [bicicleta, estaciones] = await Promise.all([
obtenerBicicleta(bicicletaId),
obtenerEstaciones(),
]);Cuando sí hay dependencia real —necesitas bicicleta.estacionId para pedir la estación— la cascada es inevitable, y ahí es donde entra el streaming del apartado 16.
- La directiva
'use client', en cuatro líneas
'use client', en cuatro líneasUn componente de servidor no tiene estado, ni efectos, ni manejadores de eventos: se ejecuta una sola vez, en el servidor, y no vuelve. Para recuperar todo eso, se pone 'use client' en la primera línea del fichero. Eso marca ese módulo y todo lo que importe como código de cliente: se empaqueta, se envía al navegador y se hidrata, y ahí vuelven a funcionar useState, useEffect, onClick y las APIs del navegador. En CicloUrbano lo llevarán SelectorTipo, BotonTema, MenuUsuario y el proveedor de tema.
La frontera entre servidor y cliente, sus reglas exactas y lo que puede y no puede cruzarla se explican a fondo en 10-03. Por ahora basta con la regla operativa: si el componente necesita estado, efectos o eventos, 'use client' arriba; si solo pinta datos, no lo pongas.
// src/componentes/SelectorTipo.jsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
const TIPOS = ['todos', 'urbana', 'electrica', 'carga'];
export default function SelectorTipo() {
const router = useRouter();
const rutaActual = usePathname();
const parametros = useSearchParams();
const tipoActual = parametros.get('tipo') ?? 'todos';
function manejarCambio(evento) {
const tipo = evento.target.value;
const nuevos = new URLSearchParams(parametros);
if (tipo === 'todos') nuevos.delete('tipo');
else nuevos.set('tipo', tipo);
router.push(`${rutaActual}?${nuevos}`);
}
return (
<label>
Tipo de bicicleta
<select value={tipoActual} onChange={manejarCambio}>
{TIPOS.map((tipo) => (
<option key={tipo} value={tipo}>{tipo}</option>
))}
</select>
</label>
);
}Es casi idéntico al SelectorTipo del módulo 6: lo único que cambia son los hooks de next/navigation en lugar de los de react-router. La lógica del filtro en la URL, que tanto defendimos en su momento, se traslada intacta.
- Renderizado dinámico: cuándo Next.js decide renderizar en cada petición
Next.js no renderiza en el servidor en cada petición por defecto. Su comportamiento por defecto es intentar prerenderizar la ruta en la construcción (eso es SSG, y es el tema de 10-02). Solo pasa a renderizar en cada petición —renderizado dinámico, que es el SSR propiamente dicho— cuando detecta que la ruta no puede conocerse de antemano.
Estos son los detonantes:
| Detonante | Por qué obliga a renderizar en cada petición |
|---|---|
fetch(..., { cache: 'no-store' }) |
Los datos deben ser frescos en cada visita |
await cookies() |
Depende de quién pide la página |
await headers() |
Depende de la petición concreta |
La prop searchParams en una page |
Depende de la URL completa, que no se conoce al construir |
export const dynamic = 'force-dynamic' |
Declaración explícita |
connection() de next/server |
Declaración explícita de que se espera a la petición |
Importante en Next.js 15: fetch ya no se cachea por defecto. Si no dices nada, se comporta como no-store y la ruta pasa a dinámica. Para que sea estática hay que pedirlo (cache: 'force-cache' o next: { revalidate: N }). En versiones anteriores era al revés, así que mucho material antiguo dice lo contrario.
En CicloUrbano, el catálogo público es el caso de libro del renderizado dinámico: muestra disponibilidad en tiempo real, así que servir HTML de hace media hora sería mentir al usuario.
// src/app/page.jsx
import ListaBicicletas from '@/componentes/ListaBicicletas';
import SelectorTipo from '@/componentes/SelectorTipo';
import ResumenFlota from '@/componentes/ResumenFlota';
export const metadata = {
title: 'Catálogo',
description: 'Consulta las bicicletas disponibles ahora mismo en cada estación.',
};
export default async function PaginaCatalogo({ searchParams }) {
// searchParams es una promesa en Next.js 15.
const { tipo } = await searchParams;
const respuesta = await fetch('http://localhost:3001/bicicletas', {
cache: 'no-store',
});
const bicicletas = await respuesta.json();
const visibles = tipo && tipo !== 'todos'
? bicicletas.filter((bici) => bici.tipo === tipo)
: bicicletas;
const disponibles = bicicletas.filter((b) => b.estado === 'disponible').length;
return (
<>
<ResumenFlota total={bicicletas.length} disponibles={disponibles} />
<SelectorTipo />
<ListaBicicletas bicicletas={visibles} />
</>
);
}Fíjate en tres decisiones:
- El filtrado se hace en el servidor. Lo que llega al navegador es solo el HTML de las bicicletas visibles. En la SPA se descargaban todas y se filtraban en el cliente con
useCatalogoFiltrado. SelectorTipoes de cliente, peroListaBicicletasyResumenFlotason de servidor: no tienen estado, solo pintan. No se descargan.- Al cambiar el filtro,
router.pushprovoca una nueva petición al servidor que devuelve el HTML actualizado. La navegación sigue siendo de cliente —no hay recarga completa— pero el contenido lo genera el servidor.
Puedes verificar que funciona: Ctrl+U sobre http://localhost:3000/?tipo=electrica y busca «Eléctrica Pro» en el código fuente. Ahí está, en el HTML, antes de cualquier JavaScript.
- Metadatos y SEO
Toda la promesa del apartado 3 se cumple aquí. Next.js ofrece dos vías: la exportación estática metadata, que ya has visto, y la función generateMetadata para los casos en que el título depende de los datos.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { notFound } from 'next/navigation';
import EtiquetaEstado from '@/componentes/EtiquetaEstado';
import PanelReserva from '@/componentes/PanelReserva';
async function obtenerBicicleta(bicicletaId) {
const respuesta = await fetch(`http://localhost:3001/bicicletas/${bicicletaId}`, {
cache: 'no-store',
});
if (!respuesta.ok) return null;
return respuesta.json();
}
export async function generateMetadata({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenerBicicleta(bicicletaId);
if (!bicicleta) {
return { title: 'Bicicleta no encontrada' };
}
const titulo = `${bicicleta.modelo} · ${bicicleta.precioHora.toFixed(2)} €/h`;
const descripcion =
`Bicicleta ${bicicleta.tipo} del modelo ${bicicleta.modelo}. ` +
`Alquiler por horas desde ${bicicleta.precioHora.toFixed(2)} € en CicloUrbano.`;
return {
title: titulo,
description: descripcion,
alternates: { canonical: `/bicicletas/${bicicleta.id}` },
openGraph: {
title: titulo,
description: descripcion,
type: 'website',
url: `https://ciclourbano.test/bicicletas/${bicicleta.id}`,
images: [{ url: `/imagenes/${bicicleta.id}.jpg`, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image' },
};
}
export default async function PaginaFichaBicicleta({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenerBicicleta(bicicletaId);
if (!bicicleta) notFound();
return (
<article>
<h1>{bicicleta.modelo}</h1>
<EtiquetaEstado estado={bicicleta.estado} />
<dl>
<dt>Tipo</dt><dd>{bicicleta.tipo}</dd>
<dt>Precio</dt><dd>{bicicleta.precioHora.toFixed(2)} €/h</dd>
<dt>Estación</dt><dd>{bicicleta.estacionId}</dd>
</dl>
<PanelReserva bicicleta={bicicleta} />
</article>
);
}Detalles que importan:
generateMetadatay el componente piden los mismos datos, y sin embargojson-serversolo recibe una petición: Next.js deduplica automáticamente losfetchidénticos dentro del mismo render. Esto se llama request memoization y evita tener que pasar datos entre ambos.openGraphytwitterproducen las etiquetas<meta property="og:...">que leen WhatsApp, Slack y LinkedIn. Con esto,bici-002compartida en un chat muestra su tarjeta propia.alternates.canonicalevita contenido duplicado si la misma ficha es accesible por varias URLs.- El
titlese combina con eltemplatede la plantilla raíz: el resultado final esEléctrica Pro · 4.00 €/h · CicloUrbano.
Para comprobar el resultado sin desplegar nada:
- Sesión sin
localStorage
localStorageEn la SPA, la sesión de CicloUrbano vive en sliceSesion y se persiste en localStorage con useAlmacenLocal. En el servidor ese mecanismo no existe, y no es un detalle de implementación: es una consecuencia lógica. El servidor renderiza el HTML antes de que el navegador ejecute nada, así que solo puede saber de la sesión lo que venga en la propia petición HTTP.
Y en una petición HTTP la sesión viaja en las cookies, que se leen con cookies():
// src/app/layout.jsx (fragmento)
import { cookies } from 'next/headers';
import MenuUsuario from '@/componentes/MenuUsuario';
export default async function PlantillaRaiz({ children }) {
// cookies() es asíncrono en Next.js 15 y hace la ruta dinámica.
const almacen = await cookies();
const sesion = almacen.get('sesion_ciclourbano');
const usuario = sesion ? JSON.parse(sesion.value) : null;
return (
<html lang="es">
<body>
<Cabecera>
<MenuUsuario usuario={usuario} />
</Cabecera>
<main>{children}</main>
<PieDePagina />
</body>
</html>
);
}Comparación de los dos modelos:
localStorage (SPA) |
Cookies (SSR) | |
|---|---|---|
| ¿Lo ve el servidor? | Nunca | Sí, en cada petición |
| ¿Se puede renderizar el nombre del usuario en el HTML? | No | Sí |
| Accesible desde JavaScript | Siempre | Solo si no es HttpOnly |
| Protección frente a XSS | Ninguna | Alta con HttpOnly |
| Tamaño | ~5 MB | ~4 KB |
| Se envía en cada petición | No | Sí (coste de ancho de banda) |
La conclusión práctica es doble. Primero: el MenuUsuario con el nombre correcto llega ya en el HTML, sin el parpadeo de «invitado → Ana Ribera» que tenía la SPA. Segundo, y más importante: una cookie HttpOnly es más segura que localStorage, porque un ataque de XSS no puede leerla desde JavaScript. Si el escaparate y la SPA comparten sesión, la cookie es el mecanismo correcto para los dos.
Ten presente que usar cookies() convierte la ruta en dinámica, y como aquí se usa en la plantilla raíz, toda la aplicación pasa a renderizarse en cada petición. Es un precio alto. La forma de evitarlo —mover la lectura de la cookie a un componente pequeño envuelto en <Suspense>, dejando el resto estático— es material de 10-02 y 10-03.
- Streaming: el HTML por partes
Queda una pieza por nombrar. Con lo visto hasta ahora, el servidor espera a tener todos los datos, renderiza todo el HTML y lo envía de golpe. Si la disponibilidad de una bicicleta tarda 800 ms en calcularse, el usuario espera 800 ms sin ver nada, igual que antes: hemos cambiado el sitio donde se espera, no el hecho de esperar.
El streaming rompe eso: el servidor envía primero el marco de la página —cabecera, título de la bicicleta, pie— y va enviando los trozos que faltan a medida que se resuelven, sin cerrar la conexión. En Next.js se activa de dos maneras:
- Poniendo un
loading.jsxen la carpeta de la ruta, que Next.js convierte automáticamente en un<Suspense fallback={...}>alrededor de la página. - Envolviendo a mano la parte lenta en
<Suspense>dentro del componente.
// src/app/loading.jsx
import EsqueletoPagina from '@/componentes/EsqueletoPagina';
export default function Cargando() {
return <EsqueletoPagina filas={5} />;
}Reconocerás el mecanismo: es exactamente el <Suspense fallback> de 08-04, aplicado a datos en lugar de a código. Y esa es la promesa que quedó pendiente entonces: el mecanismo completo de Suspense, el streaming del HTML y el modelo de componentes de servidor que hay debajo de toda esta lección son el contenido de 10-03.
Errores Comunes y Consejos
- Creer que el SSR elimina el JavaScript. No lo hace: el paquete se descarga igual y la aplicación se hidrata. Lo que mejora es cuándo se ve el contenido, no cuánto pesa. Si tu problema es el peso, la solución sigue siendo la del módulo 8.
- Usar
window,documentolocalStoragedurante el render. En el servidor no existen y el render falla conReferenceError. Van dentro deuseEffect, siempre. - Poner
'use client'en la plantilla raíz «para que todo funcione». Es la forma más rápida de anular todas las ventajas: todo el árbol pasa a ser de cliente. La regla es la contraria: la frontera, lo más abajo posible. - Olvidar
awaitenparams,searchParams,cookies()oheaders(). En Next.js 15 son promesas. Sinawaitobtendrás un objeto raro o un aviso, y muchos tutoriales anteriores a la versión 15 los usan sin esperar. - Suponer que
fetchse cachea. En Next.js 15 el comportamiento por defecto es no cachear. Si quieres caché, pídela explícitamente (10-02). - Renderizar fechas con
toLocaleString()sin fijar zona ni idioma. El servidor está en UTC; el navegador, no. Es la causa número uno de errores de hidratación. UsatimeZoneylocaleexplícitos, o formatea en un efecto. - Migrar la aplicación entera de golpe. El escaparate público y el panel de gestión tienen requisitos opuestos. Migra lo que gana con SSR y deja lo demás como SPA: es una arquitectura legítima, no una solución a medias.
- No comprobar el resultado en el HTML crudo.
Ctrl+Uocurlson la única forma honesta de saber qué llega realmente. El inspector de elementos siempre muestra la página ya hidratada y te dará una falsa sensación de éxito. - Consejo: no lleves TanStack Query al escaparate por inercia. En un componente de servidor no aporta nada, porque no hay caché de cliente que gestionar. Sigue siendo la herramienta correcta en la SPA de Vite y en las islas de cliente que necesiten datos.
Ejercicios
Ejercicio 1. Para cada pantalla de CicloUrbano, elige la estrategia de renderizado más adecuada (CSR, SSR, SSG o ISR) y justifícala en una frase. Indica además si va en ciclourbano-web o se queda en la SPA de Vite.
| Pantalla | Contenido |
|---|---|
Catálogo público / |
Bicicletas con disponibilidad en vivo |
Ficha /bicicletas/bici-002 |
Modelo, precio y estado; cambia varias veces al día |
/condiciones |
Texto legal; cambia dos veces al año |
/reservas |
Reservas del usuario identificado |
/taller |
Panel de operario con incidencias en vivo |
/estaciones |
3 estaciones; el nombre y el barrio no cambian, las plazas libres sí |
Ejercicio 2. Este componente provoca un error de hidratación y, además, un fallo en el servidor. Identifica los tres problemas y reescríbelo.
// src/componentes/PanelReserva.jsx
function PanelReserva({ bicicleta }) {
const ultimaVisita = localStorage.getItem('ultimaVisita');
const referencia = `REF-${Math.floor(Math.random() * 10000)}`;
const ahora = new Date().toLocaleTimeString();
return (
<aside>
<p>Hora actual: {ahora}</p>
<p>Referencia de reserva: {referencia}</p>
{ultimaVisita && <p>Tu última visita: {ultimaVisita}</p>}
<button onClick={() => alert('Reservando…')}>
Reservar {bicicleta.modelo}
</button>
</aside>
);
}Ejercicio 3. Crea la ruta /estaciones/[estacionId]/incidencias del escaparate. Debe: pedir las incidencias a http://localhost:3001/incidencias?estacionId=<id> sin caché, devolver un 404 real si la estación no existe, exportar un generateMetadata con el título Incidencias de <nombre de la estación>, y mostrar un mensaje cuando no haya ninguna incidencia. Indica también qué ficheros adicionales crearías en esa carpeta y para qué.
Soluciones
Solución 1.
| Pantalla | Estrategia | Dónde | Justificación |
|---|---|---|---|
Catálogo / |
SSR | ciclourbano-web |
La disponibilidad debe ser real en cada visita y la página tiene que ser indexable |
Ficha bici-002 |
ISR | ciclourbano-web |
Cambia poco y son cientos de páginas: prerenderizar y revalidar cada pocos minutos da lo mejor de ambos |
/condiciones |
SSG | ciclourbano-web |
Contenido fijo: generarlo en cada petición es desperdiciar servidor |
/reservas |
CSR | SPA de Vite | Datos privados por usuario, ningún valor de SEO, mucha interacción |
/taller |
CSR | SPA de Vite | Detrás de RutaProtegida con rol operario; nada que indexar |
/estaciones |
ISR o SSR | ciclourbano-web |
El listado es casi fijo, pero las plazas libres cambian: ISR corto, o SSR si se quiere exactitud absoluta |
Solución 2. Los tres problemas son:
localStorage.getItemdurante el render: no existe en el servidor y el render falla conReferenceError.Math.random(): produce un valor distinto en servidor y cliente → discrepancia de hidratación.new Date().toLocaleTimeString(): el servidor está en UTC y el navegador en otra zona → otra discrepancia. Además, elonClickobliga a que el componente sea de cliente.
// src/componentes/PanelReserva.jsx
'use client';
import { useState, useEffect, useId } from 'react';
function PanelReserva({ bicicleta, referencia }) {
// Identificador estable entre servidor y cliente.
const idPanel = useId();
// Valores iniciales deterministas.
const [ultimaVisita, setUltimaVisita] = useState(null);
const [ahora, setAhora] = useState(null);
useEffect(() => {
setUltimaVisita(window.localStorage.getItem('ultimaVisita'));
setAhora(new Date().toLocaleTimeString('es-ES'));
window.localStorage.setItem('ultimaVisita', new Date().toISOString());
}, []);
return (
<aside aria-labelledby={idPanel}>
<h2 id={idPanel}>Reserva</h2>
{ahora && <p>Hora actual: {ahora}</p>}
<p>Referencia de reserva: {referencia}</p>
{ultimaVisita && <p>Tu última visita: {ultimaVisita}</p>}
<button onClick={() => alert('Reservando…')}>
Reservar {bicicleta.modelo}
</button>
</aside>
);
}
export default PanelReserva;Claves de la corrección: la referencia aleatoria se genera en el servidor y se pasa como prop, así el HTML y la hidratación coinciden; el almacenamiento y la hora se leen en useEffect, después de hidratar; y los valores que aún no existen se renderizan condicionalmente para no producir diferencias.
Solución 3.
// src/app/estaciones/[estacionId]/incidencias/page.jsx
import { notFound } from 'next/navigation';
import ListaAvisos from '@/componentes/ListaAvisos';
const API = 'http://localhost:3001';
async function obtenerEstacion(estacionId) {
const respuesta = await fetch(`${API}/estaciones/${estacionId}`, { cache: 'no-store' });
return respuesta.ok ? respuesta.json() : null;
}
export async function generateMetadata({ params }) {
const { estacionId } = await params;
const estacion = await obtenerEstacion(estacionId);
if (!estacion) return { title: 'Estación no encontrada' };
return {
title: `Incidencias de ${estacion.nombre}`,
description: `Incidencias abiertas en la estación ${estacion.nombre} (${estacion.barrio}).`,
};
}
export default async function PestanaIncidencias({ params }) {
const { estacionId } = await params;
// Las dos peticiones son independientes: en paralelo.
const [estacion, respuestaIncidencias] = await Promise.all([
obtenerEstacion(estacionId),
fetch(`${API}/incidencias?estacionId=${estacionId}`, { cache: 'no-store' }),
]);
if (!estacion) notFound();
const incidencias = await respuestaIncidencias.json();
if (incidencias.length === 0) {
return <p>No hay incidencias abiertas en {estacion.nombre}.</p>;
}
return <ListaAvisos avisos={incidencias} />;
}Ficheros adicionales en esa carpeta y su motivo:
loading.jsx: mientras se piden las incidencias, la cabecera de la estación (que está en ellayoutdel nivel superior) permanece visible y solo la pestaña muestra su esqueleto.error.jsx: sijson-serverfalla, se captura ahí sin tumbar la cabecera ni las pestañas; debe llevar'use client'y recibe las propserroryreset.not-found.jsx: opcional, para dar un mensaje específico de estación inexistente en lugar del genérico de la raíz.
Fíjate en la elegancia de la anidación: los tres ficheros solo afectan al subárbol de la pestaña, exactamente como los LimiteDeError por zona del módulo 4.
Conclusión
Esta lección ha cruzado la frontera que anunciaba el cierre del módulo 9. El punto de partida era un <div id="root"> vacío y tres viajes encadenados —HTML, JavaScript, datos— antes de ver una bicicleta; el punto de llegada es un servidor que envía el HTML de bici-002 ya construido, con su título, su precio y sus etiquetas Open Graph, listo para el navegador, para Google y para la tarjeta de previsualización de un chat.
Quedan fijadas las cuatro estrategias de renderizado, que son el mapa de todo el módulo: CSR genera el HTML en el navegador y sirve para lo privado e interactivo; SSR lo genera en cada petición y sirve para lo fresco e indexable; SSG lo genera una vez en la construcción; e ISR lo regenera cada cierto tiempo. Y queda claro que no se elige una para todo el proyecto, sino una por ruta.
Del funcionamiento, lo esencial es la hidratación: el servidor manda HTML inerte y React lo reutiliza en el navegador para engancharle estado y manejadores. De ahí sale la única regla que hay que interiorizar de verdad: durante el render, un componente solo puede usar información que el servidor también tiene. Fecha local, Math.random(), localStorage, window y el tamaño de la ventana viven en useEffect, no en el cuerpo del componente.
En herramientas queda en marcha ciclourbano-web, un proyecto Next.js 15 con App Router que convive con la SPA de Vite sin sustituirla, con el criterio de reparto explícito: al escaparate va lo público, lo indexable y lo compartible —catálogo, ficha de bicicleta, estaciones, informativas—; a la SPA se quedan /acceso, /reservas y /taller. Las rutas se dibujan con carpetas (page, layout, loading, error, not-found, [bicicletaId]), el <Outlet /> se convierte en children, los datos se piden con async/await dentro del propio componente de servidor —sin useEffect, sin useQuery, sin estados de carga manuales—, y params, searchParams, cookies() y headers() son promesas en Next.js 15. El renderizado dinámico se activa cuando la ruta no puede conocerse de antemano, y el catálogo con disponibilidad en vivo lo activa con cache: 'no-store'. El SEO deja de ser un problema con metadata y generateMetadata, y la sesión pasa de localStorage a cookies, que además es la opción más segura.
Han quedado dos deudas explícitas, y las dos se pagan pronto. La primera es la directiva 'use client', que aquí has usado como una regla operativa y que en 10-03 se explica como lo que es: la frontera entre dos mundos. La segunda es el streaming, que hemos nombrado sin desarrollar.
Pero antes hay que cerrar la mitad derecha de la tabla de estrategias. Fíjate en que en toda la lección hemos forzado el renderizado dinámico con cache: 'no-store', como si el servidor tuviera que trabajar en cada visita. Para el catálogo con disponibilidad en vivo tiene sentido; para las condiciones del servicio, que cambian dos veces al año, es un desperdicio absurdo: se está generando el mismo HTML miles de veces al día. La próxima lección va al otro extremo —generar el HTML una sola vez, en la construcción— y luego busca el punto intermedio que resuelve el 90 % de los casos reales: la regeneración incremental. La próxima lección es Generación de Sitios Estáticos (SSG) con Next.js.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
