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

  1. Lo que el navegador recibe hoy: un <div> vacío
  2. Qué implica para el primer pintado y para las conexiones lentas
  3. Lo que ven los buscadores y las tarjetas de previsualización
  4. Las cuatro estrategias de renderizado
  5. Qué es la hidratación
  6. Discrepancias entre servidor y cliente
  7. Por qué hace falta un framework
  8. Next.js 15: crear ciclourbano-web
  9. Rutas por carpetas: layout, page, loading, error, not-found
  10. Del mapa de React Router al mapa de Next.js
  11. Componentes de servidor: datos con async/await
  12. La directiva 'use client', en cuatro líneas
  13. Renderizado dinámico: cuándo Next.js decide renderizar en cada petición
  14. Metadatos y SEO
  15. Sesión sin localStorage
  16. Streaming: el HTML por partes

  1. Lo que el navegador recibe hoy: un <div> vacío

Abre 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.

  1. 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.

  1. 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:

CicloUrbano
ciclourbano.test

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.

  1. 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.

  1. 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.

  1. 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     ← cliente

Las 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 useEffect se ejecuta después de la hidratación, ya en el navegador, donde window existe. 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 fija data-tema antes 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.

  1. 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.

  1. Next.js 15: crear ciclourbano-web

Aquí 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:

npx create-next-app@latest ciclourbano-web

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.

cd ciclourbano-web
npm run dev

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;
}

  1. Rutas por carpetas: layout, page, loading, error, not-found

En 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 un layout sin 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.
  • metadata es un objeto exportado, no un componente. El template: '%s · CicloUrbano' hace que cualquier página que exporte title: 'Estaciones' acabe siendo Estaciones · 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.

  1. 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        → PestanaIncidencias

Compá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:

  • params es una promesa en Next.js 15. Se espera con await. Lo mismo ocurre con searchParams, cookies() y headers(). 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 el not-found.jsx más cercano con un código HTTP 404 de verdad. En la SPA, PaginaNoEncontrada se 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.

  1. Componentes de servidor: datos con async/await

Este 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:3001 funcionaría perfectamente.
  • Se pueden usar secretos. Una clave de API en process.env.CLAVE_API no 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.

  1. La directiva 'use client', en cuatro líneas

Un 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.

  1. 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.
  • SelectorTipo es de cliente, pero ListaBicicletas y ResumenFlota son de servidor: no tienen estado, solo pintan. No se descargan.
  • Al cambiar el filtro, router.push provoca 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.

  1. 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:

  • generateMetadata y el componente piden los mismos datos, y sin embargo json-server solo recibe una petición: Next.js deduplica automáticamente los fetch idénticos dentro del mismo render. Esto se llama request memoization y evita tener que pasar datos entre ambos.
  • openGraph y twitter producen las etiquetas <meta property="og:..."> que leen WhatsApp, Slack y LinkedIn. Con esto, bici-002 compartida en un chat muestra su tarjeta propia.
  • alternates.canonical evita contenido duplicado si la misma ficha es accesible por varias URLs.
  • El title se combina con el template de la plantilla raíz: el resultado final es Eléctrica Pro · 4.00 €/h · CicloUrbano.

Para comprobar el resultado sin desplegar nada:

curl -s http://localhost:3000/bicicletas/bici-002 | grep -o '<meta property="og:[^>]*>'

  1. Sesión sin localStorage

En 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.

  1. 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.jsx en 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, document o localStorage durante el render. En el servidor no existen y el render falla con ReferenceError. Van dentro de useEffect, 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 await en params, searchParams, cookies() o headers(). En Next.js 15 son promesas. Sin await obtendrás un objeto raro o un aviso, y muchos tutoriales anteriores a la versión 15 los usan sin esperar.
  • Suponer que fetch se 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. Usa timeZone y locale explí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+U o curl son 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:

  1. localStorage.getItem durante el render: no existe en el servidor y el render falla con ReferenceError.
  2. Math.random(): produce un valor distinto en servidor y cliente → discrepancia de hidratación.
  3. new Date().toLocaleTimeString(): el servidor está en UTC y el navegador en otra zona → otra discrepancia. Además, el onClick obliga 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 el layout del nivel superior) permanece visible y solo la pestaña muestra su esqueleto.
  • error.jsx: si json-server falla, se captura ahí sin tumbar la cabecera ni las pestañas; debe llevar 'use client' y recibe las props error y reset.
  • 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados