La lección anterior dejó el escaparate ciclourbano-web funcionando con renderizado dinámico: cada visita provoca un render en el servidor. Para el catálogo con disponibilidad en vivo eso es lo correcto. Para la página de condiciones del servicio, que cambia dos veces al año, es un despilfarro difícil de justificar: el servidor genera exactamente el mismo HTML miles de veces al día, consume CPU, añade latencia y puede caerse. Esta lección va al otro extremo de la tabla del módulo —generar el HTML una sola vez, en la construcción— y después busca el punto intermedio que resuelve la mayoría de los casos reales: la regeneración incremental. Aprenderás a decidir qué contenido gana con ser estático, a hacer que Next.js prerenderice rutas dinámicas como la ficha de cada bicicleta, a leer la salida de next build para saber qué ha hecho realmente, a caducar y refrescar el HTML por tiempo o bajo demanda, y a cerrar la arquitectura híbrida de CicloUrbano: escaparate estático y aplicación de gestión en la SPA.

Contenido

  1. El desperdicio de renderizar mil veces lo mismo
  2. Qué es exactamente la generación estática
  3. Inventario de CicloUrbano: qué gana con ser estático y qué no
  4. Cómo decide Next.js entre estático y dinámico
  5. Leer la salida de next build: ○, ● y ƒ
  6. generateStaticParams: prerenderizar rutas dinámicas
  7. dynamicParams: qué pasa con un identificador nuevo
  8. Revalidación incremental (ISR)
  9. Obsoleto mientras se revalida: la línea temporal
  10. Revalidación bajo demanda: revalidatePath y revalidateTag
  11. Elegir bien el tiempo de revalidación
  12. Contenido desde ficheros locales o desde un CMS
  13. Rutas estáticas especiales: sitemap, robots y las imágenes de previsualización
  14. Imágenes y fuentes: next/image y next/font
  15. Cuándo no conviene el estático
  16. Tabla resumen definitiva y arquitectura híbrida

  1. El desperdicio de renderizar mil veces lo mismo

Pon números al escaparate de CicloUrbano. Supón 50.000 visitas diarias a la página de condiciones del servicio, y que renderizarla cuesta 40 ms de CPU en el servidor.

Renderizado dinámico (SSR) Generación estática (SSG)
Renders al día 50.000 0 (uno en la construcción)
CPU consumida ~33 minutos diarios ~40 ms, una vez
TTFB típico 80-250 ms 10-30 ms desde el CDN
¿Sobrevive si la API cae? No Sí
¿Sobrevive a un pico de tráfico? Solo escalando Sí, el CDN absorbe
Coste de alojamiento Servidor Node siempre encendido Ficheros en un CDN

Todas las filas apuntan en la misma dirección y el motivo es uno solo: el resultado no depende de la petición. Cuando el HTML es idéntico para todo el mundo y no cambia entre visitas, generarlo cada vez es trabajo repetido.

Hay una diferencia adicional que no aparece en la tabla y que suele decidir la arquitectura: la resiliencia. Una página estática ya está escrita en el disco de un CDN. Si json-server deja de responder, la ficha de bici-002 sigue sirviéndose con los últimos datos conocidos. Con SSR, esa misma ficha devuelve un 500. Para el escaparate público de una empresa, que la web siga en pie cuando el sistema interno falla no es un lujo: es lo que se espera.

  1. Qué es exactamente la generación estática

La generación estática consiste en ejecutar los componentes durante next build, guardar el HTML resultante en disco y servir ese fichero a todo el mundo.

flowchart LR
    subgraph CONSTRUCCION["next build · una vez"]
        A["Componentes de servidor"] --> B["fetch a la API"]
        B --> C["React renderiza"]
        C --> D["HTML + payload RSC<br/>guardados en disco"]
    end
    D --> E["CDN"]
    E --> F["Visitante 1"]
    E --> G["Visitante 2"]
    E --> H["Visitante 50.000"]

Tres precisiones que evitan malentendidos habituales:

  • Estático no significa «sin React». El HTML prerenderizado se hidrata igual que el del SSR, y las islas de cliente (SelectorTipo, BotonTema) siguen siendo interactivas. Lo único que cambia es cuándo se generó ese HTML.
  • Estático no significa «sin datos». Los fetch se ejecutan; simplemente se ejecutan en la máquina de construcción, no en cada petición.
  • Estático no significa «para siempre». Con ISR, ese HTML caduca y se regenera. Es lo que veremos a partir del apartado 8.

El código de una página estática es idéntico al de una dinámica. No hay una API distinta: lo único que cambia es si usas algo que obligue a esperar a la petición.

// src/app/condiciones/page.jsx — página estática sin hacer nada especial
export const metadata = {
  title: 'Condiciones del servicio',
  description: 'Condiciones de alquiler de bicicletas de CicloUrbano.',
};

export default function PaginaCondiciones() {
  return (
    <article>
      <h1>Condiciones del servicio</h1>
      <p>El alquiler se factura por horas completas desde el momento del desbloqueo.</p>
      <h2>Fianzas</h2>
      <p>Las bicicletas de carga requieren una fianza de 30 € reembolsable.</p>
    </article>
  );
}

Esta página no lee cookies, no lee searchParams y no hace ningún fetch sin caché. Next.js la prerenderiza sin que se lo pidas.

  1. Inventario de CicloUrbano: qué gana con ser estático y qué no

El criterio se puede reducir a dos preguntas: ¿el resultado depende de quién pide la página? y ¿con qué frecuencia cambia?

Pantalla ¿Depende del usuario? Frecuencia de cambio Estrategia Por qué
/condiciones, /sobre-nosotros, /tarifas No Dos veces al año SSG puro Texto fijo; regenerarlo en cada visita no aporta nada
/estaciones (listado) No Alta o baja según qué se muestre ISR 5 min Nombre y barrio son fijos; las plazas libres cambian
/bicicletas/[bicicletaId] (ficha) No Varias veces al día ISR 60 s Cientos de páginas, cambios moderados, mucho valor de SEO
/ (catálogo con disponibilidad en vivo) No, pero debe ser exacto Constante SSR Mostrar disponibilidad obsoleta engaña al usuario
/reservas Sí Constante CSR (SPA) Datos privados; nada que prerenderizar
/taller Sí, además por rol Constante CSR (SPA) Detrás de RutaProtegida; ningún valor de SEO

Observa el matiz de la ficha de bicicleta frente al catálogo. Los dos muestran el estado de la bicicleta, pero el compromiso es distinto: el catálogo promete disponibilidad ahora mismo y por eso no puede permitirse datos de hace un minuto; la ficha es sobre todo una página de contenido —modelo, tipo, precio, estación—, y un desfase de 60 segundos en la etiqueta de estado es aceptable si a cambio se sirve desde el CDN. La decisión no es técnica, es de producto: cuánta obsolescencia tolera cada pantalla.

  1. Cómo decide Next.js entre estático y dinámico

La regla del App Router se enuncia en una frase:

Toda ruta es estática por defecto. Se vuelve dinámica en cuanto usa algo que solo se conoce en el momento de la petición.

Esos «algo» son exactamente los detonantes que ya viste en 10-01, ahora en su papel completo:

Detonante Efecto Cómo evitarlo si quieres estático
fetch(..., { cache: 'no-store' }) Dinámica cache: 'force-cache' o next: { revalidate: N }
Un fetch sin opciones (Next.js 15) Dinámica Igual que el anterior: la caché hay que pedirla
await cookies() Dinámica Aislar en un componente de cliente o dentro de <Suspense>
await headers(), connection() Dinámica Igual
La prop searchParams de una page Dinámica Leer el parámetro en un componente de cliente con useSearchParams
export const dynamic = 'force-dynamic' Dinámica Quitarlo
export const revalidate = 0 Dinámica Poner un valor mayor que cero

Y los dos interruptores explícitos que conviene conocer:

// Fuerza el comportamiento de toda la ruta. Valores posibles:
export const dynamic = 'auto';           // por defecto: Next.js decide
export const dynamic = 'force-static';   // fuerza estático (cookies() devuelve vacío)
export const dynamic = 'force-dynamic';  // fuerza render en cada petición
export const dynamic = 'error';          // estático, y ERROR si algo lo hace dinámico

dynamic = 'error' es una herramienta excelente en un proyecto real: convierte «creía que esta página era estática» en un fallo de construcción. Es la misma filosofía del análisis estático del módulo 9 aplicada al renderizado.

Un aviso importante sobre searchParams: usarlo en una page la hace dinámica entera, porque el servidor necesita la URL completa. Si solo quieres reaccionar a ?tipo= sin perder el prerenderizado, la solución es leer el parámetro en un componente de cliente y filtrar allí, o poner cada filtro en su propia ruta (/catalogo/electricas).

  1. Leer la salida de next build: ○, ● y ƒ

No hay que suponer nada: next build dice exactamente qué ha hecho con cada ruta.

npm run build
Route (app)                                Size  First Load JS  Revalidate
┌ ○ /                                     1.2 kB        102 kB
├ ○ /_not-found                            142 B         88 kB
├ ● /bicicletas/[bicicletaId]             0.9 kB        101 kB          1m
├   ├ /bicicletas/bici-001
├   ├ /bicicletas/bici-002
├   └ [+3 more paths]
├ ○ /condiciones                           136 B         88 kB
├ ● /estaciones                            310 B         89 kB          5m
├ ● /estaciones/[estacionId]               450 B         92 kB          5m
├ ƒ /catalogo                             1.1 kB        102 kB
└ ○ /sitemap.xml                           136 B         88 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Cómo se lee esta tabla:

Símbolo Significado Cuándo se genera el HTML
○ Static Ruta sin parámetros, prerenderizada En next build
● SSG Ruta dinámica prerenderizada con generateStaticParams En next build, una página por parámetro
ƒ Dynamic Renderizada en el servidor en cada petición En cada visita

Y las columnas:

  • Size: el JavaScript propio de esa ruta.
  • First Load JS: el total que descarga quien entra por ahí, incluido el código compartido. Es la cifra del módulo 8 y sigue importando.
  • Revalidate: el tiempo de caducidad, si lo hay.

Las líneas indentadas bajo ● son las páginas concretas generadas. Si esperabas una ○ y ves una ƒ, algo ha activado un detonante: revisa cookies(), searchParams y las opciones de fetch. Este comando debería formar parte de tu rutina antes de desplegar, igual que npm test.

  1. generateStaticParams: prerenderizar rutas dinámicas

Una ruta como /bicicletas/[bicicletaId] tiene un problema evidente: en la construcción, Next.js no sabe qué identificadores existen. Puede prerenderizar /condiciones porque es una sola página, pero no puede adivinar que hay una bici-001 y una bici-002.

generateStaticParams es la función que se lo dice. Se ejecuta durante la construcción, devuelve la lista de valores posibles del segmento dinámico, y Next.js genera una página por cada uno.

// src/app/bicicletas/[bicicletaId]/page.jsx
import { notFound } from 'next/navigation';
import EtiquetaEstado from '@/componentes/EtiquetaEstado';

const API = 'http://localhost:3001';

export async function generateStaticParams() {
  const respuesta = await fetch(`${API}/bicicletas`);
  const bicicletas = await respuesta.json();

  // Cada objeto del array corresponde a un [bicicletaId] del nombre de carpeta.
  return bicicletas.map((bicicleta) => ({ bicicletaId: bicicleta.id }));
}

async function obtenerBicicleta(bicicletaId) {
  const respuesta = await fetch(`${API}/bicicletas/${bicicletaId}`, {
    next: { revalidate: 60, tags: [`bicicleta-${bicicletaId}`] },
  });
  return respuesta.ok ? respuesta.json() : null;
}

export async function generateMetadata({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenerBicicleta(bicicletaId);
  if (!bicicleta) return { title: 'Bicicleta no encontrada' };
  return {
    title: `${bicicleta.modelo} · ${bicicleta.precioHora.toFixed(2)} €/h`,
    description: `Bicicleta ${bicicleta.tipo} disponible para alquiler por horas.`,
  };
}

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} />
      <p>{bicicleta.precioHora.toFixed(2)} €/h · estación {bicicleta.estacionId}</p>
    </article>
  );
}

Análisis línea a línea de lo que ha cambiado respecto a 10-01:

  • La forma del valor devuelto importa. Cada objeto debe tener una clave con el nombre exacto del segmento: la carpeta es [bicicletaId], así que la clave es bicicletaId. Y el valor es siempre una cadena, aunque el identificador fuese numérico.
  • cache: 'no-store' ha desaparecido y en su lugar hay next: { revalidate: 60 }. Esto es lo que permite que la ruta sea estática (● en el build) y a la vez se refresque cada minuto.
  • La etiqueta tags: ['bicicleta-<id>'] no hace nada por sí sola; es el anclaje para la revalidación bajo demanda del apartado 10.
  • generateStaticParams y generateMetadata y el componente comparten los fetch gracias a la deduplicación automática que ya viste.

En rutas anidadas, cada nivel dinámico aporta su función. Para /estaciones/[estacionId]/incidencias, el generateStaticParams de [estacionId] se hereda y no hay que repetirlo:

// src/app/estaciones/[estacionId]/layout.jsx
export async function generateStaticParams() {
  const respuesta = await fetch('http://localhost:3001/estaciones');
  const estaciones = await respuesta.json();
  return estaciones.map((estacion) => ({ estacionId: estacion.id }));
}

Con las tres estaciones del canon, la construcción genera est-01, est-02 y est-03, cada una con su pestaña índice y su pestaña de incidencias: seis páginas.

  1. dynamicParams: qué pasa con un identificador nuevo

Pregunta inevitable: el equipo de CicloUrbano da de alta bici-006 después del despliegue. Esa página no existe en el disco. ¿Qué ocurre cuando alguien la visita?

Lo decide dynamicParams:

// Por defecto: true
export const dynamicParams = true;
Valor Comportamiento ante un parámetro no generado
true (por defecto) Se renderiza en el servidor la primera vez, se guarda en caché y las siguientes visitas se sirven desde ella
false Devuelve un 404 directamente, sin llamar al componente

Cuándo usar cada uno:

  • true para catálogos que crecen: bicicletas, estaciones, artículos. Es el comportamiento deseado en CicloUrbano: la nueva bicicleta funciona al instante, solo que la primera visita paga el render.
  • false cuando el conjunto es cerrado y conocido: idiomas soportados, categorías fijas, páginas legales. Así una URL inventada no dispara un render inútil, algo que además cierra una vía de abuso.

Con dynamicParams = true, generateStaticParams deja de ser una lista exhaustiva y pasa a ser una lista de precalentamiento. Un patrón muy útil en catálogos grandes es prerenderizar solo lo más visitado y dejar que el resto se genere bajo demanda:

export async function generateStaticParams() {
  const respuesta = await fetch('http://localhost:3001/bicicletas');
  const bicicletas = await respuesta.json();

  // Solo las disponibles: son las que la gente consulta y comparte.
  return bicicletas
    .filter((bicicleta) => bicicleta.estado === 'disponible')
    .map((bicicleta) => ({ bicicletaId: bicicleta.id }));
}

Con el canon de CicloUrbano, esto genera bici-001, bici-004 y bici-005 en la construcción; bici-002 (alquilada) y bici-003 (en mantenimiento) se generarán la primera vez que alguien las pida.

  1. Revalidación incremental (ISR)

Aquí está la idea que hace utilizable la generación estática en un sitio con datos reales, y es la cuarta columna de la tabla del módulo.

ISR (Incremental Static Regeneration) sirve la página estática guardada, y cuando esa página lleva más de N segundos sin regenerarse, la vuelve a generar en segundo plano. El visitante que llega justo después de la caducidad no espera: recibe la versión antigua, y la nueva queda lista para el siguiente.

Se declara de dos formas, que se pueden combinar:

// A) Por ruta: afecta a toda la página.
export const revalidate = 300; // segundos

// B) Por petición: cada fetch tiene su propio ciclo de vida.
const respuesta = await fetch(`${API}/bicicletas/${id}`, {
  next: { revalidate: 60 },
});

Diferencias y cuál usar:

export const revalidate = N next: { revalidate: N }
Alcance Toda la ruta Una petición concreta
Granularidad Baja Alta
Cuándo usarlo La página tiene un ritmo único Datos con ritmos distintos en la misma página

Cuando conviven, gana el más corto: si la ruta declara 300 y un fetch declara 60, la página se regenera cada 60 segundos.

Ejemplo real con dos ritmos en la misma página, el listado de estaciones:

// src/app/estaciones/page.jsx
import TarjetaEstacion from '@/componentes/TarjetaEstacion';

const API = 'http://localhost:3001';

// Tope de la página: como mucho, 10 minutos de obsolescencia.
export const revalidate = 600;

export default async function PaginaEstaciones() {
  const [estaciones, ocupacion] = await Promise.all([
    // El nombre y el barrio de una estación no cambian nunca: una hora está bien.
    fetch(`${API}/estaciones`, { next: { revalidate: 3600 } }).then((r) => r.json()),
    // Las plazas libres cambian constantemente: un minuto.
    fetch(`${API}/ocupacion`, { next: { revalidate: 60 } }).then((r) => r.json()),
  ]);

  return (
    <section>
      <h1>Nuestras estaciones</h1>
      <ul>
        {estaciones.map((estacion) => (
          <li key={estacion.id}>
            <TarjetaEstacion
              estacion={estacion}
              plazasLibres={ocupacion[estacion.id] ?? 0}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

En el build, esta ruta aparece como ● con Revalidate: 1m —el mínimo efectivo—, y la página se sirve desde el CDN salvo la regeneración ocasional.

  1. Obsoleto mientras se revalida: la línea temporal

Este es el mecanismo exacto, y merece entenderlo bien porque explica una confusión muy común: «he cambiado el dato, he recargado y sigo viendo lo viejo». No es un fallo; es el diseño.

Con revalidate = 60 y una regeneración que tarda 2 segundos:

sequenceDiagram
    participant V1 as Visitante A (t=0s)
    participant V2 as Visitante B (t=75s)
    participant V3 as Visitante C (t=78s)
    participant N as Next.js
    participant API as API

    V1->>N: GET /bicicletas/bici-002
    N-->>V1: HTML cacheado (fresco) · rapido
    Note over N: Cache generada en t=0. Caduca en t=60.

    V2->>N: GET /bicicletas/bici-002
    N-->>V2: HTML cacheado (OBSOLETO) · rapido
    Note over N: Caducada: se lanza la regeneracion EN SEGUNDO PLANO
    N->>API: GET /bicicletas/bici-002
    API-->>N: JSON actualizado
    Note over N: Nueva cache lista en t=77

    V3->>N: GET /bicicletas/bici-002
    N-->>V3: HTML NUEVO · rapido

Lo que hay que retener:

  • Nadie espera nunca. Ni siquiera el visitante B, que es quien dispara la regeneración. Este patrón se llama stale-while-revalidate.
  • El visitante B ve contenido obsoleto, y es una decisión consciente: se prefiere una respuesta rápida y ligeramente antigua a una respuesta lenta y exacta.
  • revalidate: 60 no significa «se regenera cada 60 segundos». Significa «pasados 60 segundos, la próxima visita dispara la regeneración». Si nadie entra en tres días, no se regenera nada. La caché es perezosa, no un cronómetro.
  • La regeneración es única. Si llegan cien visitantes a la vez tras la caducidad, se lanza una sola regeneración; los cien reciben la versión antigua.

De aquí sale una consecuencia práctica: en desarrollo (npm run dev) no verás este comportamiento, porque el modo de desarrollo renderiza siempre. Para comprobar el ISR de verdad hay que ejecutar npm run build && npm run start. Es la misma disciplina que en el módulo 8 con el Profiler: se mide sobre el build de producción.

  1. Revalidación bajo demanda: revalidatePath y revalidateTag

El ISR por tiempo resuelve la mayoría de los casos, pero deja un hueco: cuando el equipo de CicloUrbano da de alta bici-006 o baja el precio de las eléctricas, no quiere esperar 60 segundos ni 10 minutos. Quiere que el cambio se refleje ya.

La revalidación bajo demanda invierte el control: en lugar de que la caché caduque sola, alguien la invalida explícitamente.

Hay dos funciones, importadas de next/cache:

Función Qué invalida Cuándo usarla
revalidatePath(ruta) El HTML cacheado de una ruta concreta Sabes exactamente qué página cambia
revalidateTag(etiqueta) Todas las peticiones marcadas con esa etiqueta, estén donde estén El dato aparece en varias páginas

Las etiquetas se declaran en el propio fetch, y ahí está la potencia del mecanismo:

// src/consultas/escaparate.js
const API = 'http://localhost:3001';

export async function obtenerBicicletas() {
  const respuesta = await fetch(`${API}/bicicletas`, {
    next: { revalidate: 300, tags: ['bicicletas'] },
  });
  return respuesta.json();
}

export async function obtenerBicicleta(bicicletaId) {
  const respuesta = await fetch(`${API}/bicicletas/${bicicletaId}`, {
    next: { revalidate: 60, tags: ['bicicletas', `bicicleta-${bicicletaId}`] },
  });
  return respuesta.ok ? respuesta.json() : null;
}

Fíjate en que obtenerBicicleta lleva dos etiquetas: una general y una específica. Eso permite invalidar con dos granularidades distintas: revalidateTag('bicicleta-bici-002') afecta solo a esa ficha; revalidateTag('bicicletas') afecta a la ficha, al listado y a cualquier otra página que use esos datos, sin que tengas que enumerarlas.

El disparador es un manejador de ruta (route.js), que en el App Router es el equivalente a un endpoint de API. El panel interno de CicloUrbano lo llamará al guardar una bicicleta:

// src/app/api/revalidar/route.js
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(peticion) {
  // 1. Autenticación: sin esto, cualquiera puede tirar tu caché.
  const secreto = peticion.headers.get('x-secreto-revalidacion');
  if (secreto !== process.env.SECRETO_REVALIDACION) {
    return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
  }

  // 2. Qué hay que invalidar.
  const { etiqueta, ruta } = await peticion.json();

  if (etiqueta) revalidateTag(etiqueta);
  if (ruta) revalidatePath(ruta);

  return NextResponse.json({ revalidado: true, momento: Date.now() });
}

Y así se invoca desde el sistema de gestión cuando alguien edita bici-002:

curl -X POST http://localhost:3000/api/revalidar \
  -H "Content-Type: application/json" \
  -H "x-secreto-revalidacion: $SECRETO_REVALIDACION" \
  -d '{"etiqueta":"bicicleta-bici-002"}'

Tres advertencias importantes:

  • La autenticación no es opcional. Un endpoint de revalidación abierto permite a cualquiera forzar renders en bucle: es una denegación de servicio servida en bandeja.
  • revalidatePath con una ruta dinámica requiere el segundo argumento: revalidatePath('/bicicletas/[bicicletaId]', 'page') invalida todas las fichas.
  • La invalidación no regenera de inmediato: marca la caché como caducada. La regeneración la dispara la siguiente visita, como en el apartado 9.

La misma función revalidateTag se puede llamar desde una acción de servidor, que es la forma natural de hacerlo cuando el propio Next.js gestiona el formulario. Las acciones de servidor son material de 10-03.

  1. Elegir bien el tiempo de revalidación

El valor de revalidate no se elige al azar. La pregunta correcta es: ¿cuántos segundos de desfase entre la realidad y lo que ve el usuario son aceptables aquí?

Contenido de CicloUrbano Valor razonable Razonamiento
Condiciones del servicio, «sobre nosotros» false (nunca caduca) Cambia con un despliegue; revalidar bajo demanda si hace falta
Tarifas 86400 (1 día) Un cambio de precio se comunica; un día de desfase es tolerable
Listado de estaciones (datos fijos) 3600 (1 hora) Nombre, barrio y plazas totales apenas cambian
Ficha de bicicleta 60 (1 minuto) Buen equilibrio entre frescura y coste, con etiqueta para forzar
Plazas libres por estación 60 (1 minuto) Orientativo por definición; nadie espera exactitud al segundo
Catálogo con disponibilidad en vivo 0 / SSR Aquí la obsolescencia sí engaña al usuario
Panel de taller No aplica Datos privados; va en la SPA

Dos heurísticas útiles:

  • Empieza generoso y baja si hace falta. Un valor alto con revalidación bajo demanda como salida de emergencia suele ser mejor que un valor bajo: cuesta menos y da control exacto.
  • Si el número correcto parece ser 0, la ruta no es estática. Fuérzala a dinámica y sé explícito; no simules SSR con revalidate: 1, porque pagas la complejidad de las dos estrategias sin la ventaja de ninguna.

  1. Contenido desde ficheros locales o desde un CMS

Un componente de servidor no está limitado a fetch. Como se ejecuta en Node, puede leer el sistema de ficheros, algo impensable en la SPA. Es la forma más directa de gestionar las páginas informativas de CicloUrbano en Markdown.

npm install gray-matter remark remark-html
contenido/
├── condiciones.md
├── sobre-nosotros.md
└── tarifas.md
---
titulo: Condiciones del servicio
descripcion: Condiciones de alquiler de bicicletas de CicloUrbano.
actualizado: 2026-03-14
---

## Facturación

El alquiler se factura por horas completas desde el desbloqueo.
// src/app/[pagina]/page.jsx — una ruta para todas las informativas
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import { notFound } from 'next/navigation';

const DIRECTORIO = path.join(process.cwd(), 'contenido');

export async function generateStaticParams() {
  const ficheros = await fs.readdir(DIRECTORIO);
  return ficheros.map((fichero) => ({ pagina: fichero.replace(/\.md$/, '') }));
}

// Conjunto cerrado: cualquier otra URL debe ser un 404.
export const dynamicParams = false;

async function leerPagina(nombre) {
  try {
    const bruto = await fs.readFile(path.join(DIRECTORIO, `${nombre}.md`), 'utf8');
    const { data, content } = matter(bruto);
    const procesado = await remark().use(html).process(content);
    return { meta: data, html: procesado.toString() };
  } catch {
    return null;
  }
}

export async function generateMetadata({ params }) {
  const { pagina } = await params;
  const contenido = await leerPagina(pagina);
  if (!contenido) return { title: 'Página no encontrada' };
  return { title: contenido.meta.titulo, description: contenido.meta.descripcion };
}

export default async function PaginaInformativa({ params }) {
  const { pagina } = await params;
  const contenido = await leerPagina(pagina);
  if (!contenido) notFound();

  return (
    <article>
      <h1>{contenido.meta.titulo}</h1>
      <p><small>Actualizado el {contenido.meta.actualizado}</small></p>
      <div dangerouslySetInnerHTML={{ __html: contenido.html }} />
    </article>
  );
}

Puntos a destacar:

  • fs en un componente. Solo funciona porque es de servidor. Si añadieras 'use client' a este fichero, la construcción fallaría: no hay sistema de ficheros en el navegador.
  • dynamicParams = false es lo correcto aquí: el conjunto de páginas informativas está cerrado y una URL inventada debe dar 404.
  • dangerouslySetInnerHTML es aceptable porque el Markdown lo escribe el equipo, no un usuario anónimo. Con contenido de terceros habría que sanearlo antes.
  • Sustituir los ficheros por un CMS (Contentful, Strapi, Sanity, WordPress headless) no cambia la estructura: solo cambia leerPagina por un fetch a su API, y la revalidación bajo demanda se dispara desde el webhook del CMS al route.js del apartado 10.

  1. Rutas estáticas especiales: sitemap, robots y las imágenes de previsualización

Next.js reserva algunos nombres de fichero que generan artefactos estáticos en la construcción. Son los que completan el trabajo de SEO empezado en 10-01.

El mapa del sitio. Un fichero que exporta una función por defecto y produce sitemap.xml:

// src/app/sitemap.js
const BASE = 'https://ciclourbano.test';

export default async function sitemap() {
  const [bicicletas, estaciones] = await Promise.all([
    fetch('http://localhost:3001/bicicletas').then((r) => r.json()),
    fetch('http://localhost:3001/estaciones').then((r) => r.json()),
  ]);

  const fijas = ['', '/estaciones', '/condiciones', '/tarifas'].map((ruta) => ({
    url: `${BASE}${ruta}`,
    lastModified: new Date(),
    changeFrequency: ruta === '' ? 'hourly' : 'monthly',
    priority: ruta === '' ? 1 : 0.6,
  }));

  const fichas = bicicletas.map((bicicleta) => ({
    url: `${BASE}/bicicletas/${bicicleta.id}`,
    lastModified: new Date(),
    changeFrequency: 'daily',
    priority: 0.8,
  }));

  const paradas = estaciones.map((estacion) => ({
    url: `${BASE}/estaciones/${estacion.id}`,
    changeFrequency: 'weekly',
    priority: 0.7,
  }));

  return [...fijas, ...fichas, ...paradas];
}

Las reglas para robots. Aquí se hace explícito el reparto de la arquitectura:

// src/app/robots.js
export default function robots() {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        // Lo que vive en la SPA o es privado no debe rastrearse.
        disallow: ['/api/', '/taller', '/reservas'],
      },
    ],
    sitemap: 'https://ciclourbano.test/sitemap.xml',
  };
}

Las imágenes de previsualización abierta, generadas en la construcción. En 10-01 apuntábamos images: [{ url: '/imagenes/bici-002.jpg' }] a un fichero que alguien tenía que crear a mano. Next.js puede dibujarla con ImageResponse:

// src/app/bicicletas/[bicicletaId]/opengraph-image.jsx
import { ImageResponse } from 'next/og';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export const alt = 'Bicicleta de CicloUrbano';

export default async function ImagenPrevisualizacion({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await fetch(`http://localhost:3001/bicicletas/${bicicletaId}`)
    .then((r) => r.json());

  return new ImageResponse(
    (
      <div style={{
        display: 'flex', flexDirection: 'column', justifyContent: 'center',
        width: '100%', height: '100%', padding: 80,
        background: '#12805c', color: '#ffffff', fontSize: 64,
      }}>
        <div style={{ fontSize: 32, opacity: 0.85 }}>CicloUrbano</div>
        <div style={{ fontWeight: 700 }}>{bicicleta.modelo}</div>
        <div style={{ fontSize: 40 }}>
          {bicicleta.precioHora.toFixed(2)} €/h · {bicicleta.tipo}
        </div>
      </div>
    ),
    size,
  );
}

Notas: el JSX de ImageResponse no es React del navegador —es una descripción que se convierte en PNG—, y por eso solo admite un subconjunto de CSS basado en Flexbox, con display: 'flex' obligatorio en los contenedores. Como convive con generateStaticParams, las imágenes se generan en la construcción y se sirven desde el CDN, con lo que la tarjeta de WhatsApp de cada bicicleta es distinta sin trabajo de diseño por ficha.

  1. Imágenes y fuentes: next/image y next/font

Dos optimizaciones que el escaparate aprovecha directamente y que no existen en el proyecto Vite sin instalar nada.

next/image sustituye a <img> y resuelve de una vez varios problemas de rendimiento:

import Image from 'next/image';

<Image
  src={`/imagenes/${bicicleta.id}.jpg`}
  alt={`Bicicleta ${bicicleta.modelo} de CicloUrbano`}
  width={640}
  height={480}
  priority={esLaPrincipal}
  sizes="(max-width: 768px) 100vw, 640px"
/>
Qué hace Por qué importa
Convierte a WebP/AVIF y redimensiona bajo demanda Reduce el peso de la imagen de forma drástica
Genera srcset a partir de sizes El móvil no descarga la versión de escritorio
Carga perezosa por defecto Las tarjetas de abajo no compiten con las de arriba
Reserva el espacio con width/height Elimina el salto de contenido (CLS)
priority desactiva la carga perezosa y precarga Mejora el LCP en la imagen principal

alt es obligatorio; la accesibilidad del módulo 3 sigue aplicando igual.

next/font autoaloja las fuentes en la construcción, sin peticiones a servidores externos:

// src/app/layout.jsx (fragmento)
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--fuente-base',
});

export default function PlantillaRaiz({ children }) {
  return (
    <html lang="es" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Esto descarga la fuente en next build, la sirve desde tu propio dominio (mejor privacidad y una conexión menos), y genera los descriptores necesarios para que el cambio de fuente no mueva el texto, que es otra fuente clásica de CLS.

  1. Cuándo no conviene el estático

El estático es tan cómodo que es fácil pasarse. Estos son los límites reales:

Situación Problema Qué hacer
200.000 páginas que cambian a diario La construcción tarda horas y se repite en cada despliegue generateStaticParams con las más visitadas + dynamicParams: true
Contenido personalizado por usuario Un HTML por usuario no tiene sentido SSR con cookies(), o CSR dentro de una isla de cliente
Datos que deben ser exactos al segundo El ISR sirve por definición contenido obsoleto SSR sin caché
Búsqueda con parámetros libres Infinitas combinaciones de searchParams Ruta dinámica, o filtrar en el cliente
Precios, existencias, saldos Mostrar un dato caducado tiene consecuencias legales o económicas SSR, o marcar la parte crítica como dinámica
Contenido tras autenticación No se puede prerenderizar lo que depende de una sesión SPA o SSR

Hay un patrón que resuelve la mayoría de estos casos sin renunciar al estático: una página estática con un hueco dinámico. La ficha de bici-002 se prerenderiza entera —modelo, precio, descripción, imagen— y solo el bloque de disponibilidad en vivo se rellena aparte, envuelto en <Suspense>. Se obtiene el TTFB del CDN y la exactitud del SSR en la misma página. Ese patrón es el corazón de 10-03, y es la razón de que la próxima lección sea sobre el modelo y no sobre más funciones de Next.js.

  1. Tabla resumen definitiva y arquitectura híbrida

Cierre del mapa. Esta es la tabla de las cuatro estrategias aplicada pantalla por pantalla a CicloUrbano, con la implementación concreta:

Pantalla Estrategia Proyecto Implementación
/ catálogo con disponibilidad SSR ciclourbano-web fetch(..., { cache: 'no-store' })
/bicicletas/[bicicletaId] ISR 60 s ciclourbano-web generateStaticParams + next: { revalidate: 60, tags }
/estaciones ISR 1-10 min ciclourbano-web revalidate por fetch, dos ritmos
/estaciones/[estacionId] (+pestañas) ISR 5 min ciclourbano-web generateStaticParams en el layout
/condiciones, /tarifas, /sobre-nosotros SSG ciclourbano-web Markdown local + dynamicParams: false
sitemap.xml, robots.txt, imágenes OG SSG ciclourbano-web sitemap.js, robots.js, opengraph-image.jsx
/acceso CSR SPA Vite PaginaAcceso + sliceSesion
/reservas, /reservas/nueva CSR SPA Vite TanStack Query + sliceReservas
/taller CSR SPA Vite RutaProtegida + RequiereRol

Y así queda la arquitectura completa:

flowchart TB
    subgraph PUBLICO["Escaparate publico · ciclourbano-web (Next.js 15)"]
        CDN["CDN"]
        SSG["SSG: condiciones, tarifas, sitemap, robots"]
        ISR["ISR: fichas de bicicleta, estaciones"]
        SSR["SSR: catalogo con disponibilidad"]
    end

    subgraph GESTION["Aplicacion de gestion · SPA (Vite + React Router)"]
        SPA["CSR: acceso, reservas, taller"]
        RTK["Redux Toolkit + TanStack Query"]
    end

    API[("API · json-server<br/>localhost:3001")]

    SSG --> CDN
    ISR --> CDN
    SSR --> API
    ISR --> API
    SPA --> RTK --> API

    CDN --> U1["Visitante anonimo<br/>y buscadores"]
    SSR --> U1
    SPA --> U2["Usuario identificado<br/>Ana Ribera / Marc Sole"]

    U1 -. "Iniciar sesion" .-> SPA

Esta arquitectura es habitual en producción y merece nombrarse: el escaparate y la aplicación son dos proyectos, dos despliegues y dos modelos de renderizado, unidos por una API común y por una cookie de sesión compartida. No es una transición a medias hacia Next.js: es la decisión correcta, porque las dos partes tienen requisitos opuestos. Lo público necesita ser rápido, indexable y resistente; lo privado necesita ser interactivo y estar siempre al día.

Sobre el proyecto final. Conviene decirlo aquí con claridad para que no haya confusión: el Módulo 11 construye la aplicación completa con Vite + React Router, la que llevas nueve módulos montando. Next.js es una herramienta que ahora sabes usar y situar, no un cambio de rumbo del curso. Cuando en un proyecto real tengas que decidir, tendrás el criterio de la tabla de arriba.

Errores Comunes y Consejos

  • Probar el ISR con npm run dev. En desarrollo, Next.js renderiza siempre y no cachea nada. El ISR solo se comprueba con npm run build && npm run start.
  • Esperar que revalidate: 60 regenere sola cada minuto. No hay temporizador: la regeneración la dispara una visita después de la caducidad. Sin tráfico, no hay regeneración.
  • Añadir console.log para depurar y encontrarlo vacío. Si la página es estática, ese console.log se ejecutó una vez, durante next build, y su salida está en el registro de la construcción, no en el del servidor.
  • Devolver claves con el nombre equivocado en generateStaticParams. Si la carpeta es [bicicletaId], la clave debe ser bicicletaId. Y el valor, una cadena.
  • Usar searchParams sin darse cuenta de que rompe el prerenderizado. Una sola lectura convierte la página en ƒ. Si necesitas parámetros de consulta sin perder el estático, léelos en cliente con useSearchParams.
  • Dejar /api/revalidar sin autenticación. Es un vector de denegación de servicio: cualquiera puede forzar renders en bucle. Secreto en cabecera, siempre.
  • Fijar revalidate: 1 «por si acaso». Es SSR con complejidad extra. Si necesitas frescura absoluta, declara la ruta dinámica y sé explícito.
  • Olvidar width y height en next/image. Sin ellos no se reserva espacio y vuelve el salto de contenido que el componente venía a evitar.
  • Consejo: haz de next build parte de tu integración continua. Combinado con export const dynamic = 'error' en las rutas que deben ser estáticas, un fetch mal configurado deja de ser un problema de producción y pasa a ser un fallo de construcción.
  • Consejo: prerenderiza solo lo que se visita. En catálogos grandes, generateStaticParams con lo más popular y dynamicParams: true da el 95 % del beneficio con una fracción del tiempo de construcción.

Ejercicios

Ejercicio 1. Este next build no es el esperado. Las tres primeras rutas deberían ser estáticas o SSG y aparecen como dinámicas. Para cada una, propón la causa más probable y la corrección.

Route (app)                             Size  First Load JS
┌ ƒ /condiciones                       136 B         88 kB
├ ƒ /bicicletas/[bicicletaId]         0.9 kB        101 kB
├ ƒ /estaciones                        310 B         89 kB
└ ƒ /                                 1.2 kB        102 kB

Sabes además que: /condiciones solo contiene texto en JSX; /bicicletas/[bicicletaId] tiene generateStaticParams; /estaciones hace dos fetch sin opciones; y / lee searchParams. Indica también cuál de las cuatro debe seguir siendo ƒ.

Ejercicio 2. Diseña la estrategia de caché de /estaciones/[estacionId], que muestra: nombre, barrio y plazas totales (fijos); la flota aparcada ahora mismo (cambia cada pocos minutos); y las incidencias abiertas (deben verse en cuanto un operario da una de alta). Escribe el código de las peticiones con sus revalidate y sus tags, y explica cómo se fuerza la actualización de las incidencias sin esperar.

Ejercicio 3. El equipo de CicloUrbano quiere una sección de blog en el escaparate: artículos en Markdown en contenido/blog/, listado en /blog y detalle en /blog/[slug]. Los artículos se publican una o dos veces al mes. Decide la estrategia de renderizado, el valor de dynamicParams y qué hay que añadir al sitemap.js, y justifica cada decisión en una frase.

Soluciones

Solución 1.

Ruta Causa probable Corrección
/condiciones Algo global la contamina: casi seguro un await cookies() en layout.jsx (el MenuUsuario de 10-01), o un export const dynamic = 'force-dynamic' heredado Sacar la lectura de la cookie del layout de servidor: leerla en un componente de cliente o aislarla en un componente envuelto en <Suspense>
/bicicletas/[bicicletaId] Tiene generateStaticParams, pero los fetch van sin caché: en Next.js 15 eso basta para hacerla dinámica Cambiar a next: { revalidate: 60, tags: [...] }
/estaciones Mismo motivo: fetch sin opciones ya no se cachea en Next.js 15 Añadir next: { revalidate: N } a cada petición
/ Lee searchParams y muestra disponibilidad en vivo Debe seguir siendo ƒ. Es el caso legítimo de SSR de todo el escaparate

El aprendizaje general: una ruta que parece estática y sale como ƒ casi siempre tiene la causa en un ancestro o en una opción de fetch olvidada.

Solución 2.

// src/app/estaciones/[estacionId]/page.jsx
const API = 'http://localhost:3001';

export async function generateStaticParams() {
  const estaciones = await fetch(`${API}/estaciones`).then((r) => r.json());
  return estaciones.map((estacion) => ({ estacionId: estacion.id }));
}

export default async function PestanaFlota({ params }) {
  const { estacionId } = await params;

  const [estacion, flota, incidencias] = await Promise.all([
    // 1. Datos fijos: una hora es más que suficiente.
    fetch(`${API}/estaciones/${estacionId}`, {
      next: { revalidate: 3600, tags: [`estacion-${estacionId}`] },
    }).then((r) => r.json()),

    // 2. Flota aparcada: cambia cada pocos minutos.
    fetch(`${API}/bicicletas?estacionId=${estacionId}`, {
      next: { revalidate: 120, tags: ['bicicletas', `flota-${estacionId}`] },
    }).then((r) => r.json()),

    // 3. Incidencias: caducidad larga porque se invalidan bajo demanda.
    fetch(`${API}/incidencias?estacionId=${estacionId}`, {
      next: { revalidate: 3600, tags: [`incidencias-${estacionId}`] },
    }).then((r) => r.json()),
  ]);

  return (
    <>
      <p>{estacion.barrio} · {estacion.plazas} plazas</p>
      <p>{flota.length} bicicletas aparcadas</p>
      <p>{incidencias.length} incidencias abiertas</p>
    </>
  );
}

La clave está en la tercera petición: no se usa un revalidate corto, sino uno largo con una etiqueta. Cuando un operario da de alta una incidencia desde el panel de taller, el sistema de gestión llama al endpoint de revalidación:

curl -X POST http://localhost:3000/api/revalidar \
  -H "x-secreto-revalidacion: $SECRETO_REVALIDACION" \
  -H "Content-Type: application/json" \
  -d '{"etiqueta":"incidencias-est-02"}'

Así se obtiene lo mejor de las dos estrategias: coste mínimo en régimen normal (una regeneración por hora como mucho) y actualización inmediata cuando de verdad hay algo nuevo. Bajar revalidate a 10 segundos daría un resultado peor y más caro.

Solución 3.

  • Estrategia: SSG puro para el listado y para el detalle. Los artículos son ficheros del repositorio, así que cualquier cambio implica un despliegue, y el despliegue ya reconstruye el sitio: una revalidación por tiempo no aportaría nada.
  • dynamicParams = false en /blog/[slug]: el conjunto de artículos está cerrado en el momento de la construcción, y un slug inventado debe devolver un 404 real en lugar de disparar un render.
  • generateStaticParams lee contenido/blog/ con fs.readdir y devuelve un { slug } por fichero.
  • En sitemap.js hay que añadir la ruta /blog y una entrada por artículo, con lastModified tomado del campo fecha del front matter —no de new Date(), que marcaría todos los artículos como recién modificados en cada construcción y degradaría la señal para los buscadores— y changeFrequency: 'monthly'.
  • Extra recomendable: un opengraph-image.jsx en /blog/[slug] con el título del artículo, para que compartir un enlace en un chat muestre una tarjeta propia.

Si en el futuro los artículos se movieran a un CMS, la estrategia pasaría a ISR con revalidación bajo demanda desde el webhook del CMS: el resto del código no cambiaría.

Conclusión

Con esta lección queda completo el mapa de renderizado que abría el módulo. En 10-01 el HTML se generaba en cada petición; aquí se genera una vez, en next build, se sirve desde un CDN y —con ISR— se refresca solo cuando hace falta.

Lo esencial de la estrategia. En el App Router toda ruta es estática por defecto y se vuelve dinámica en cuanto usa algo que solo se conoce en la petición: cookies(), headers(), searchParams o un fetch sin caché —que en Next.js 15 es el comportamiento por defecto, así que la caché hay que pedirla—. Y no se supone nada: next build lo dice, con ○ para estático, ● para SSG con generateStaticParams y ƒ para dinámico. Ese comando pertenece a la rutina previa al despliegue tanto como npm test.

De las herramientas, quedan fijadas cuatro. generateStaticParams enumera los valores de un segmento dinámico y genera una página por cada uno, con la clave nombrada exactamente como la carpeta. dynamicParams decide qué pasa con lo que no estaba: true lo renderiza bajo demanda y lo cachea —lo correcto para un catálogo que crece—, false devuelve un 404 —lo correcto para un conjunto cerrado—. revalidate, por ruta o por petición, implementa el patrón stale-while-revalidate: nadie espera nunca, alguien ve contenido ligeramente antiguo, y la caducidad no es un temporizador sino una condición que dispara la siguiente visita. Y revalidateTag / revalidatePath invierten el control para los casos en que esperar no vale: etiquetas declaradas en el fetch, un route.js autenticado y una llamada desde el sistema de gestión. La combinación ganadora, que has visto en el ejercicio 2, es caducidad larga más etiqueta: coste mínimo en reposo y actualización inmediata cuando hay novedad.

El escaparate ha ganado además su capa de SEO y rendimiento completa: sitemap.js con las fichas y las estaciones, robots.js que excluye lo que vive en la SPA, opengraph-image.jsx que dibuja una tarjeta distinta por bicicleta en la construcción, next/image con su srcset, su carga perezosa y su reserva de espacio, y next/font autoalojando la tipografía sin saltos de texto. Y las páginas informativas salen de ficheros Markdown leídos con fs desde un componente de servidor, algo que en la SPA era sencillamente imposible.

La arquitectura de CicloUrbano queda cerrada y es deliberadamente híbrida: escaparate público en ciclourbano-web —catálogo en SSR, fichas y estaciones en ISR, informativas en SSG— y aplicación de gestión en la SPA de Vite —acceso, reservas y taller en CSR con Redux Toolkit y TanStack Query—, unidas por la API común y por una cookie de sesión compartida. Y con ello, el recordatorio que ya se ha hecho arriba: el proyecto final del Módulo 11 se construye con Vite + React Router.

Queda una pieza sin explicar, y es justamente la que sostiene todo lo anterior. Has escrito 'use client' sin saber del todo qué significa; has aceptado que un componente de servidor «no se envía al navegador» sin ver cómo; has puesto un loading.jsx sabiendo que por debajo hay un <Suspense>; y el apartado 15 ha terminado prometiendo el patrón que resuelve casi todos los casos difíciles: una página estática con un hueco dinámico dentro. La próxima lección deja de lado la sintaxis de Next.js y va al modelo: qué es exactamente un límite de suspensión, cómo un componente «se suspende», cómo el servidor envía el HTML por partes, dónde se ejecuta cada componente y qué puede y no puede cruzar la frontera entre servidor y cliente. La próxima lección es Suspense y React Server Components.

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