Módulo 07: Imágenes, fuentes y metadatos

Objetivos del módulo

  • Entender qué problemas tiene una etiqueta <img> normal y cómo los resuelve next/image
  • Saber qué ocurre por dentro cuando el navegador pide /_next/image: validación, caché, redimensionado y formato
  • Elegir entre width/height, importación estática y fill, y escribir un sizes que no desperdicie bytes
  • Decidir qué imagen se carga primero con preload, loading y fetchPriority, y poner un placeholder borroso
  • Mostrar imágenes de otro servidor con remotePatterns, y entender por qué Next.js 16 bloquea las IP locales
  • Conocer uno por uno los cambios de next/image en Next.js 16 y cómo reproducirlos (o evitarlos) en Next.js 15
  • Cargar scripts de terceros con next/script sin frenar la página
  • Describir cada página con la Metadata API: metadata, generateMetadata, plantillas de título, Open Graph y URL canónica
  • Entender cómo se fusionan los metadatos de layouts y páginas, y el error más típico que provoca esa fusión
  • Generar iconos e imágenes para redes sociales con código, usando ImageResponse de next/og
  • Crear sitemap.xml, robots.txt y el manifiesto de la aplicación con ficheros TypeScript

💡 Consejo: Todo lo que ves en este módulo se ha compilado y ejecutado con Next.js 16.4 y, para las alternativas marcadas, con Next.js 15.5. Instala siempre el último parche de la 16.4: los parches corrigen errores y fallos de seguridad, pero no cambian las APIs. Las salidas que aparecen (HTML, cabeceras HTTP, mensajes de error) son reales, copiadas del laboratorio.


1. Por qué las imágenes y los metadatos tienen su propio módulo

Haz una prueba: abre la página de inicio de cualquier tienda en línea con las herramientas de desarrollo, pestaña Red, filtra por Img y mira la columna de tamaño. Lo normal es que las imágenes pesen más que todo el HTML, el CSS y el JavaScript juntos. Son, con diferencia, lo que más tarda en llegar.

Eso afecta a tres cosas que Google mide y que el usuario nota aunque no sepa ponerles nombre. Son las Core Web Vitals, que estudiarás a fondo en el módulo 20:

Métrica Qué mide Cómo la estropea una imagen
LCP (Largest Contentful Paint) Cuánto tarda en pintarse el elemento más grande de la pantalla La imagen de portada suele ser ese elemento. Si pesa 2 MB o se descubre tarde, el LCP se dispara
CLS (Cumulative Layout Shift) Cuánto “salta” el contenido mientras carga Una imagen sin dimensiones ocupa 0 px y, al llegar, empuja todo hacia abajo
INP (Interaction to Next Paint) Lo que tarda la página en responder a un clic Un script de terceros pesado bloquea el hilo principal

La segunda mitad del módulo trata de los metadatos: el título de la pestaña, la descripción que aparece en Google, la imagen que se ve al compartir un enlace por WhatsApp o LinkedIn, el icono de la pestaña, el sitemap.xml… Nadie los ve dentro de la página, pero deciden si alguien llega a ella. Una ficha de producto sin título propio aparece en Google como “Tienda EMPRESA”, igual que las otras veinte, y nadie hace clic.

Next.js trae piezas para las dos cosas:

  • next/image: un componente que sustituye a <img> y optimiza la imagen en el servidor.
  • next/script: un componente para cargar scripts externos en el momento adecuado.
  • La Metadata API: un objeto metadata o una función generateMetadata que Next convierte en etiquetas del <head>.
  • Los ficheros especiales de metadatos: icon, opengraph-image, sitemap, robots y manifest, que pueden ser ficheros estáticos o código.

Las fuentes ya las trabajaste con next/font en el módulo 06. Aquí solo volveremos a ellas para un caso concreto: dibujar texto con tu tipografía dentro de una imagen generada.

📘 Concepto: Este módulo usa como hilo conductor la tienda pública de EMPRESA: una portada con imagen grande, un catálogo con la foto de cada producto, una ficha por producto con su propia imagen para redes sociales, y una página del equipo con las fotos de los empleados, que vienen de otro servidor. Los datos salen de la API EMPRESA (recursos/empresa-api.zip), que arrancas con node server.mjs y queda en http://localhost:3000/api. Como la API ocupa el puerto 3000, arranca Next en el 3001: npm run dev -- -p 3001.


2. El problema de <img>

Empecemos por lo que harías sin Next. Tienes la foto del producto 3 en public/productos/3.png, de 800 × 800 píxeles y unos 40 KB, y la pones en el catálogo:

export function TarjetaProducto() {
  return (
    <div className="w-48">
      <img src="/productos/3.png" alt="Portátil Básico 15" />
      <h2>Portátil Básico 15</h2>
    </div>
  )
}

Funciona, pero tiene cinco problemas que se notan en cuanto la página crece:

  1. Tamaño equivocado. La tarjeta mide 192 px de ancho, pero el navegador descarga la imagen de 800 px. En un móvil con pantalla de densidad 2 harían falta 384 px; estás enviando más del cuádruple de píxeles.
  2. Formato antiguo. Un PNG o un JPEG pesan bastante más que la misma imagen en WebP o AVIF, formatos que hoy entiende cualquier navegador.
  3. Saltos de diseño. Hasta que la imagen llega, el navegador no sabe su altura. El título se pinta arriba y luego baja de golpe. Eso es CLS.
  4. Carga innecesaria. Si el catálogo tiene 20 productos, el navegador descarga los 20 aunque solo se vean 4. El atributo loading="lazy" lo evita, pero hay que acordarse.
  5. Todo a mano. Puedes resolver lo anterior con srcset, sizes, <picture>, width, height y un servicio que genere las variantes. Es mucho trabajo, y hay que repetirlo en cada imagen.

📘 Concepto: srcset es un atributo de <img> que ofrece al navegador varias versiones de la misma imagen, y sizes le dice qué ancho ocupará en pantalla. Con las dos cosas, el navegador elige la versión más pequeña que se vea nítida. Si no recuerdas cómo funcionan, repásalo en el Curso de HTML y CSS. next/image los escribe por ti.

next/image resuelve los cinco a la vez: genera el srcset, convierte a WebP, obliga a dar las dimensiones (o a decir que la imagen llena su contenedor), pone loading="lazy" por defecto y hace el trabajo de redimensionar en el servidor, solo la primera vez que alguien pide cada tamaño.


3. Primer contacto con next/image

3.1. Una imagen de public/

Lo mínimo para usar el componente son cuatro props: src, alt, width y height.

import Image from 'next/image'

export function TarjetaProducto() {
  return (
    <div className="w-48">
      <Image src="/productos/3.png" alt="Portátil Básico 15" width={800} height={800} />
      <h2>Portátil Básico 15</h2>
    </div>
  )
}
  • src empieza por / porque apunta a public/, igual que con <img>.
  • alt es obligatorio. Describe la imagen para quien usa un lector de pantalla y para los buscadores. Si la imagen es puramente decorativa, escribe alt="" (vacío, pero presente).
  • width y height son las dimensiones reales de la imagen en píxeles, no las que ocupará en pantalla. Next las usa para calcular la proporción y reservar el hueco antes de que la imagen llegue. El tamaño en pantalla lo decide tu CSS.

⚠️ Importante: Un error muy común es poner en width y height el tamaño de la tarjeta (width={192} height={192}) pensando que así la imagen ocupará eso. No: con esos valores Next generará versiones de 192 y 384 px aunque luego tu CSS la estire a 600, y se verá borrosa. Pon las dimensiones del fichero y controla el tamaño visible con CSS (className="w-48"), o mejor, con sizes, que verás en la sección 5.

Esto es lo que genera, tal cual sale del servidor (la imagen tiene además className="w-32"):

<img alt="Calidad 80" loading="lazy" width="800" height="800" decoding="async"
  data-nimg="1" class="w-32" style="color:transparent"
  srcSet="/_next/image?url=%2Fproductos%2F4.png&amp;w=828&amp;q=75 1x,
          /_next/image?url=%2Fproductos%2F4.png&amp;w=1920&amp;q=75 2x"
  src="/_next/image?url=%2Fproductos%2F4.png&amp;w=1920&amp;q=75"/>

Fíjate en cuatro cosas:

  1. loading="lazy" y decoding="async" vienen puestos. Las imágenes se cargan cuando se acercan a la pantalla.
  2. width y height pasan al HTML. Con ellos, el navegador reserva un hueco de proporción 1:1 antes de descargar nada: adiós al salto de diseño.
  3. Las URL ya no apuntan a /productos/4.png, sino a /_next/image?url=...&w=828&q=75. Es el optimizador de imágenes de Next, que verás en la sección 4.
  4. El srcSet tiene dos versiones, 1x y 2x, para pantallas normales y de alta densidad. Como no has dicho qué ancho ocupa en pantalla, Next supone que se ve a su tamaño natural (800 px) y elige los anchos de su lista más cercanos: 828 y 1920.

3.2. Importación estática

La segunda forma de indicar el src es importar la imagen como si fuera un módulo:

import Image from 'next/image'
import portada from '@/imagenes/portada.jpg'

export default function Inicio() {
  return <Image src={portada} alt="Escaparate de la Tienda EMPRESA" placeholder="blur" />
}

Al importar, el bundler lee el fichero en tiempo de compilación y te entrega un objeto con todo lo que hace falta:

// Lo que contiene "portada" (simplificado)
{
  src: '/_next/static/media/portada.1f64mrfcg_613.jpg',
  width: 1600,
  height: 900,
  blurDataURL: 'data:image/jpeg;base64,...' // miniatura para el placeholder
}

Ventajas de importar:

  • No escribes width ni height: Next los lee del fichero.
  • El nombre lleva un hash del contenido (portada.1f64mrfcg_613.jpg). Si cambias la imagen, cambia el nombre, así que se puede guardar en caché “para siempre” sin miedo a servir la versión vieja.
  • Tienes placeholder="blur" gratis: Next genera una miniatura borrosa al compilar.
  • Si el fichero no existe, falla la compilación, no la página en producción.

Su límite es que la ruta tiene que ser fija y conocida al compilar. No puedes hacer import(/productos/${id}.png). Para imágenes que dependen de datos (la foto del producto 3, del 7, del que sea), usa una ruta de public/ o una URL remota.

  public/ con ruta Importación estática URL remota
width/height Obligatorios (o fill) Automáticos Obligatorios (o fill)
placeholder="blur" Necesita blurDataURL Automático Necesita blurDataURL
Ruta dinámica (/productos/${id}.png) Sí No Sí
Configuración necesaria Ninguna Ninguna remotePatterns
Caché del original Según cabeceras de public/ Nombre con hash, inmutable Según cabeceras del servidor remoto

💡 Consejo: Usa la importación estática para las imágenes “de diseño”, que forman parte de la aplicación: la portada, el logotipo, las ilustraciones. Usa public/ o un servidor remoto para las imágenes “de datos”, que dependen de lo que hay en la base de datos.

3.3. Dónde guardar las imágenes importadas

La importación funciona desde cualquier carpeta. En el proyecto de este módulo, la portada está en imagenes/portada.jpg, en la raíz, y se importa con el alias @/ que create-next-app configura en tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

No la pongas dentro de public/ si solo la vas a importar: lo que está en public/ se publica tal cual, así que tendrías la imagen servida dos veces, una con hash y otra sin él.


4. Qué pasa por dentro: el optimizador /_next/image

Cuando el navegador encuentra el srcSet, elige una de las URL y la pide. Esa petición no va a un fichero, sino a una ruta especial de Next que hace este recorrido:

Diagrama del recorrido de una petición a /_next/image: validación, caché en disco, imagen original, optimización con sharp y respuesta

Figura 1. El recorrido de una petición a /_next/image. Solo la primera petición de cada combinación de imagen, ancho, calidad y formato hace el trabajo pesado; las demás salen de la caché.

  1. Validación. Next comprueba los tres parámetros de la URL: url (qué imagen), w (qué ancho) y q (qué calidad). Si algo no está permitido por la configuración, responde 400 con un mensaje de texto.
  2. Caché. Busca en .next/cache/images si ya optimizó esa combinación. Si está y no ha caducado, la devuelve directamente.
  3. Original. Si no está, lee la imagen original: de public/, del resultado de una importación estática o de un servidor remoto.
  4. Optimización. La redimensiona al ancho pedido y la convierte al mejor formato que acepte el navegador (WebP por defecto), usando la biblioteca sharp, que se instala con Next.
  5. Respuesta. La envía con una cabecera Cache-Control y la guarda en la caché.

Puedes verlo con curl. La primera petición:

curl -s -D - -o /dev/null -H "Accept: image/avif,image/webp,*/*" \
  "http://localhost:3001/_next/image?url=%2Fproductos%2F4.png&w=828&q=75"
HTTP/1.1 200 OK
Cache-Control: public, max-age=14400, must-revalidate
Content-Type: image/webp
Content-Disposition: attachment; filename="4.webp"
X-Nextjs-Cache: MISS

Y la segunda, idéntica:

HTTP/1.1 200 OK
Cache-Control: public, max-age=14400, must-revalidate
Content-Type: image/webp
Content-Disposition: attachment; filename="4.webp"
X-Nextjs-Cache: HIT

Lee las cabeceras con calma, porque cada una cuenta algo:

  • Content-Type: image/webp: el original era PNG, pero el navegador anunció en Accept que entiende WebP, y Next lo ha convertido.
  • X-Nextjs-Cache: MISS y luego HIT: la primera vez se hizo el trabajo, la segunda se sirvió de la caché.
  • Cache-Control: max-age=14400: el navegador (y cualquier CDN intermedio) puede reutilizar esta respuesta durante 4 horas. Ese valor sale de la opción minimumCacheTTL, que en Next.js 16 cambió (sección 8.3).
  • Content-Disposition: attachment: si alguien abre esa URL directamente en el navegador, se descarga en lugar de mostrarse. Es una medida de seguridad para que el optimizador no pueda usarse para “servir” contenido arbitrario como si fuera tuyo.

⚠️ Importante: El optimizador trabaja en tu servidor. Cada imagen nueva que alguien pide consume CPU para redimensionarla. Por eso Next solo acepta los anchos de una lista (deviceSizes + imageSizes) y las calidades de otra (qualities): si aceptara cualquier valor, un atacante podría pedir w=1, w=2, w=3… hasta llenar tu disco y agotar tu CPU. Casi todos los cambios de next/image en Next.js 16 van en esta dirección: cerrar puertas.

4.1. Las dos listas de anchos

Next solo genera imágenes de ciertos anchos. Esos anchos salen de dos listas que puedes cambiar en next.config.ts:

Opción Valor por defecto en Next.js 16 Para qué se usa
deviceSizes [640, 750, 828, 1080, 1200, 1920, 2048, 3840] Anchos de pantalla típicos. Se usan para imágenes que ocupan todo o buena parte del ancho
imageSizes [32, 48, 64, 96, 128, 256, 384] Anchos pequeños, para imágenes que ocupan menos que la pantalla más pequeña (iconos, avatares, miniaturas)

Si pides un ancho fuera de las dos listas, el optimizador lo rechaza:

HTTP/1.1 400 Bad Request

"w" parameter (width) of 16 is not allowed

Ese 16 no es casual: en Next.js 15 estaba en la lista y en la 16 ya no. Lo verás en la sección 8.2.

4.2. Formatos: WebP y AVIF

Por defecto, formats vale ['image/webp']. Puedes añadir AVIF, que comprime más pero tarda más en codificar:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
  },
}

export default nextConfig

El orden importa: Next usa el primero de la lista que el navegador acepte. Con la configuración anterior, un navegador que acepta AVIF recibe AVIF, y si no, WebP. Ten en cuenta que cada formato se guarda por separado en la caché, así que ocupará más disco.

💡 Consejo: Si pones Next detrás de un proxy o un CDN propio, asegúrate de que reenvía la cabecera Accept del navegador. Si no lo hace, Next no sabe qué formatos admite el cliente y servirá el formato original.


5. Imágenes que se adaptan: sizes y fill

5.1. El problema del srcSet con 1x y 2x

En la sección 3.1 viste que, sin más información, Next genera un srcSet con 1x y 2x. Eso es correcto para una imagen de tamaño fijo: un avatar de 96 px siempre mide 96 px.

Pero la tarjeta del catálogo no tiene tamaño fijo. En un móvil hay 2 columnas y cada imagen ocupa la mitad de la pantalla; en un portátil hay 4 y ocupa la cuarta parte. Con 1x/2x, el navegador no puede saberlo, y descarga siempre la misma.

La solución es decirle cuánto ocupará con la prop sizes, que tiene la misma sintaxis que el atributo HTML:

<Image
  src={`/productos/${p.productoId}.png`}
  alt={p.nombre}
  width={800}
  height={800}
  sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
  className="aspect-square w-full rounded"
/>

Léelo así: “hasta 640 px de pantalla, la imagen ocupa el 50 % del ancho de la ventana; hasta 1024 px, el 33 %; a partir de ahí, el 25 %”. Tiene que coincidir con tu rejilla CSS, que en el ejemplo es grid-cols-2 sm:grid-cols-3 lg:grid-cols-4.

Con sizes, el srcSet cambia por completo. Este es el que generó Next para esa imagen (solo los anchos):

256w, 384w, 640w, 750w, 828w, 1080w, 1200w, 1920w, 2048w, 3840w

Ahora el navegador tiene diez opciones con su ancho real (w) y sabe cuánto ocupará la imagen, así que hace la cuenta él solo. En un móvil de 390 px de ancho y densidad 3: 50 % de 390 = 195 px, por 3 = 585 px de imagen. Elige la de 640w. En un portátil de 1440 px y densidad 1: 25 % de 1440 = 360 px. Elige la de 384w.

📘 Concepto: ¿Por qué empieza en 256 y no en 32? Next mira el porcentaje más pequeño que aparece en sizes (25 vw) y descarta los anchos que nunca podrían hacer falta. Es una optimización para que el srcSet no sea interminable. En la ficha de producto, con sizes="(max-width: 768px) 100vw, 50vw", el srcSet empezaba en 384w.

⚠️ Importante: Si tu sizes no coincide con el CSS, el navegador elige mal. Si declaras 25vw pero la imagen en realidad ocupa toda la pantalla en móvil, el usuario verá una imagen borrosa. Si declaras 100vw y ocupa la cuarta parte, descargará el cuádruple de lo necesario. Cuando cambies la rejilla, cambia sizes.

5.2. Cómo comprobar qué imagen ha elegido el navegador

No te fíes: compruébalo. En las herramientas de desarrollo:

  1. Pestaña Elementos, selecciona la <img> y busca en la pestaña Propiedades el valor de currentSrc. Ahí verás la URL elegida, con su w=.
  2. Pestaña Red, filtro Img: verás qué /_next/image?...&w=... se ha descargado realmente y cuánto pesa.
  3. Activa la emulación de dispositivos, cambia de un móvil a un escritorio y recarga: el w= debe cambiar.

5.3. fill: cuando la imagen llena su contenedor

A veces no conoces las dimensiones de la imagen, o no importan: quieres que ocupe todo el espacio de un contenedor, como la portada de la tienda. Para eso está fill:

import Image from 'next/image'
import portada from '@/imagenes/portada.jpg'

export default function Inicio() {
  return (
    <section className="relative h-[420px]">
      <Image
        src={portada}
        alt="Escaparate de la Tienda EMPRESA"
        fill
        sizes="100vw"
        className="object-cover"
      />
    </section>
  )
}

Con fill, Next quita width y height y pone la imagen en posición absoluta ocupando todo el padre. Este es el style que generó:

position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent

De ahí salen las tres reglas de fill:

  1. El padre debe tener posición: relative, absolute o fixed. Si no, la imagen se posiciona respecto a otro antepasado y acaba ocupando media página.
  2. El padre debe tener altura. Sin h-[420px] (o una proporción con aspect-video, por ejemplo), el contenedor mide 0 px de alto y no se ve nada.
  3. Decide cómo encaja con object-fit: object-cover recorta para llenar el hueco sin deformar; object-contain muestra la imagen entera dejando franjas vacías. Sin ninguno, la imagen se estira y se deforma.

Y una cuarta, de rendimiento: pon siempre sizes con fill. Sin él, Next supone que la imagen ocupa todo el ancho de la pantalla (100vw), que en una portada es correcto, pero en una miniatura con fill sería un despilfarro.

Captura de la portada de la Tienda EMPRESA con la imagen de cabecera ocupando todo el ancho

Figura 2. La portada de la tienda: una imagen con fill, object-cover, sizes="100vw", placeholder="blur" y preload.

5.4. El prop style y el CSS

next/image acepta className y style como cualquier <img>. Si cambias el ancho con CSS y la imagen no usa fill, pon también height: auto para que conserve la proporción:

<Image
  src="/productos/3.png"
  alt="Portátil Básico 15"
  width={800}
  height={800}
  style={{ width: '100%', height: 'auto' }}
/>

Con Tailwind, lo equivalente es className="w-full h-auto". Si solo pones w-full, la altura sigue siendo la del atributo height (800) y la imagen se deforma en pantallas estrechas.


6. Qué se carga primero

6.1. loading: perezosa o inmediata

Por defecto, todas las imágenes de next/image llevan loading="lazy": el navegador no las descarga hasta que están cerca de entrar en pantalla. Es lo correcto para el 90 % de las imágenes, pero tiene un coste para las que ya están en pantalla al cargar, porque el navegador tarda un poco más en descubrirlas.

En el catálogo, las cuatro primeras tarjetas se ven sin hacer scroll en un escritorio. Para ellas tiene sentido loading="eager":

{productos.map((p, i) => (
  <Image
    key={p.productoId}
    src={`/productos/${p.productoId}.png`}
    alt={p.nombre}
    width={800}
    height={800}
    sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
    loading={i < 4 ? 'eager' : 'lazy'}
  />
))}

En el HTML del catálogo, con 20 productos, se cuentan exactamente así:

      4 loading="eager"
     16 loading="lazy"

6.2. preload: la imagen del LCP 🆕 16.0

🆕 Desde Next.js 16.0. No existe en Next.js 15: allí TypeScript la rechaza (Type '{ ...; preload: true; }' is not assignable...). Si usas Next.js 15: usa priority, que hace lo mismo: añade un <link rel="preload"> en el <head> y quita la carga perezosa.

La imagen más grande de la portada suele ser el elemento LCP. Para que llegue cuanto antes, no basta con quitarle la carga perezosa: conviene que el navegador empiece a descargarla antes de leer el <body>, mientras procesa el <head>. Eso es una precarga, y con next/image se pide con preload:

<Image
  src={portada}
  alt="Escaparate de la Tienda EMPRESA"
  fill
  preload
  placeholder="blur"
  sizes="100vw"
  className="object-cover"
/>

El efecto se ve en el <head> de la página, que ahora empieza así (URL acortadas):

<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="preload" as="image"
  imageSrcSet="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fportada.1f64mrfcg_613.jpg&amp;w=640&amp;q=75 640w, ...
               /_next/image?url=...portada...&amp;w=3840&amp;q=75 3840w"
  imageSizes="100vw"/>

El <link rel="preload"> lleva el mismo srcSet y el mismo sizes que la imagen, de modo que el navegador elige y descarga la versión correcta antes incluso de saber que existe la <img>.

Cuándo usar preload:

  • La imagen es el elemento LCP de la página (casi siempre, la imagen principal de arriba).
  • Está “por encima del pliegue”: se ve sin hacer scroll.

Cuándo no usarla:

  • En varias imágenes a la vez. Si precargas diez, ninguna tiene prioridad y retrasas el CSS y el JavaScript.
  • Cuando el LCP cambia según el dispositivo (una imagen en móvil y otra en escritorio): precargarías las dos.
  • Junto con loading o fetchPriority: son mecanismos alternativos.

La documentación de Next añade una recomendación: en muchos casos basta con loading="eager" o fetchPriority="high", que también acelera la imagen sin añadir nada al <head>. fetchPriority es un atributo HTML estándar que next/image pasa tal cual a la <img>.

6.3. priority, obsoleta en Next.js 16 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, priority es la forma de precargar una imagen. En la 16 sigue funcionando, pero está marcada como obsoleta (@deprecated Use preload prop instead) en favor de preload. Si usas Next.js 15: sigue usando priority; al migrar, cámbiala por preload.

Comprobado en las dos versiones con la misma imagen: priority genera en ambas este <link> en el <head>:

<link rel="preload" as="image"
  imageSrcSet="/_next/image?url=%2Fproductos%2F5.png&amp;w=828&amp;q=75 1x,
               /_next/image?url=%2Fproductos%2F5.png&amp;w=1920&amp;q=75 2x"/>

y la <img> sale sin loading="lazy". El cambio de nombre es para dejar claro qué hace: “prioridad” sonaba a fetchPriority, y lo que hace es una precarga.

6.4. Placeholders: algo que mirar mientras llega

Mientras una imagen grande se descarga, el hueco reservado está vacío. La prop placeholder lo rellena:

Valor Qué muestra Requisitos
"empty" (por defecto) Nada: hueco transparente  
"blur" Una versión borrosa de la imagen Automático con importación estática; con otras, pasa blurDataURL
"data:image/..." La imagen que indiques (un color, un degradado, un SVG animado)  

Con la portada importada estáticamente, basta placeholder="blur". En el HTML verás que Next pone como fondo de la <img> un SVG con la miniatura difuminada:

background-size:cover;background-position:50% 50%;background-repeat:no-repeat;
background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 ...

Cuando la imagen real termina de cargar, el componente quita ese fondo.

Para una imagen de public/ o remota, Next no puede generar la miniatura al compilar, porque no ve el fichero. Tienes que darla tú:

// Un PNG de 1 × 1 píxel del color principal del producto, en base64
const azul = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0sPz/HwAFBQJ/qO+e2QAAAABJRU5ErkJggg=='

<Image
  src="/productos/3.png"
  alt="Portátil Básico 15"
  width={800}
  height={800}
  placeholder="blur"
  blurDataURL={azul}
/>

💡 Consejo: blurDataURL debe ser muy pequeño (10 píxeles o menos), porque va dentro del HTML. Si la base de datos guarda el color dominante de cada imagen, conviértelo en un PNG de 1 × 1 y tendrás un placeholder del color correcto sin coste.


7. Imágenes de otro servidor

7.1. remotePatterns: la lista de lo permitido

En la página del equipo, las fotos de los empleados no están en tu proyecto: las sirve otro servidor, como haría un CDN de imágenes o un servicio de almacenamiento. Para practicar, usaremos un servidor mínimo que genera la foto de cada empleado al vuelo con sharp (la misma biblioteca que usa Next, así que ya la tienes instalada). Guárdalo como servidor-fotos.mjs en la raíz del proyecto:

// servidor-fotos.mjs: un "CDN" de fotos de empleados para practicar con next/image
// Arranque: node servidor-fotos.mjs   (queda en http://localhost:4000)
import { createServer } from 'node:http'
import { pathToFileURL } from 'node:url'
import sharp from 'sharp' // viene instalado con Next.js

const colores = ['#3f51b5', '#00897b', '#f4511e', '#8e24aa', '#6d4c41', '#039be5', '#c0ca33']

// Devuelve true si la petición era una foto y ya está respondida
export async function atenderFoto(req, res) {
  const coincide = req.url.match(/^\/fotos\/empleados\/(\d+)\.png$/)
  if (!coincide) return false
  const id = Number(coincide[1])
  const color = colores[id % colores.length]
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
    <rect width="400" height="400" fill="${color}"/>
    <circle cx="200" cy="150" r="80" fill="#ffffff" opacity="0.85"/>
    <rect x="80" y="250" width="240" height="180" rx="110" fill="#ffffff" opacity="0.85"/>
    <text x="200" y="175" text-anchor="middle" font-family="Arial" font-size="64"
      font-weight="700" fill="${color}">${id}</text>
  </svg>`
  const png = await sharp(Buffer.from(svg)).png().toBuffer()
  res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' })
  res.end(png)
  return true
}

// Solo escucha si se ejecuta directamente (no si se importa)
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const puerto = Number(process.env.PORT ?? 4000)
  createServer(async (req, res) => {
    if (!(await atenderFoto(req, res))) {
      res.writeHead(404)
      res.end()
    }
  }).listen(puerto, () => console.log(`Fotos en http://localhost:${puerto}/fotos/empleados/100.png`))
}

Con la API en el 3000, este servidor en el 4000 y Next en el 3001, abre http://localhost:4000/fotos/empleados/100.png en el navegador y verás un avatar con el número 100.

Ahora intenta usarla con next/image:

<Image src="http://localhost:4000/fotos/empleados/100.png" alt="Foto de Ana García" width={400} height={400} />

Next se niega. En desarrollo, la página muestra un error con el motivo (este es el texto que lanza el código de next/image):

Error: Invalid src prop (http://localhost:4000/fotos/empleados/100.png) on `next/image`,
hostname "localhost" is not configured under images in your `next.config.js`

Next no optimiza imágenes de cualquier sitio, porque entonces tu servidor sería un servicio gratuito de redimensionado para todo internet. Tienes que declarar de dónde aceptas imágenes con remotePatterns:

// next.config.ts
import type { NextConfig } from 'next'

const FOTOS = process.env.FOTOS_URL ?? 'http://localhost:4000'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [new URL(`${FOTOS}/fotos/**`)],
  },
}

export default nextConfig

La forma corta, con new URL(...), acepta comodines en la ruta. La forma larga con un objeto permite más control:

remotePatterns: [
  {
    protocol: 'https',
    hostname: 'cdn.empresa.es',
    port: '',
    pathname: '/fotos/**',
    search: '',
  },
],
Campo Qué restringe Si lo omites
protocol http o https Se acepta cualquiera
hostname El dominio. *.empresa.es admite un subdominio; **.empresa.es, cualquier nivel Obligatorio
port El puerto. '' significa “el de por defecto” Se acepta cualquiera
pathname La ruta. * es un segmento; **, cualquier número de segmentos al final Se acepta cualquiera
search La cadena de consulta exacta (?v=1). '' prohíbe que haya Se acepta cualquiera

⚠️ Importante: Sé todo lo concreto que puedas. Un patrón como { hostname: '**' } vuelve a abrir la puerta que remotePatterns cierra. Y omitir pathname en un servicio compartido (un bucket de almacenamiento, por ejemplo) permite optimizar las imágenes de otros clientes de ese servicio a tu costa.

Un intento de optimizar algo que no cumple ningún patrón acaba así:

HTTP/1.1 400 Bad Request

"url" parameter is not allowed

📘 Concepto: Quizá encuentres en tutoriales antiguos la opción images.domains: ['cdn.empresa.es']. Está obsoleta desde Next.js 14 porque no permite limitar protocolo, puerto ni ruta. Usa siempre remotePatterns.

7.2. La página del equipo

Con la configuración hecha, la página es un Server Component que pide los empleados a la API y pinta una tarjeta por empleado:

// app/equipo/page.tsx
import type { Metadata } from 'next'
import { FOTOS, obtenerEquipo } from '@/lib/tienda'
import { FotoEmpleado } from './FotoEmpleado'

export const metadata: Metadata = {
  title: 'Nuestro equipo',
  description: 'Las personas que atienden la Tienda EMPRESA.',
}

export default async function Equipo() {
  const equipo = await obtenerEquipo()
  return (
    <main className="p-6">
      <h1 className="mb-4 text-2xl font-bold">Nuestro equipo</h1>
      <ul className="grid grid-cols-2 gap-4 sm:grid-cols-4">
        {equipo.map((e) => (
          <li key={e.empId} className="flex flex-col items-center rounded-lg bg-white p-4 shadow">
            <FotoEmpleado
              src={`${FOTOS}/fotos/empleados/${e.empId}.png`}
              nombre={`${e.nombre} ${e.apellidos}`}
            />
            <p className="mt-2 font-semibold">{e.nombre} {e.apellidos}</p>
            <p className="text-sm text-slate-500">{e.oficio}</p>
          </li>
        ))}
      </ul>
    </main>
  )
}

La foto va en un componente aparte porque necesita reaccionar a un error de carga, y eso exige un handler onError. Las funciones no se pueden pasar de un Server Component al cliente, como viste en el módulo 05, así que este componente lleva 'use client':

// app/equipo/FotoEmpleado.tsx
'use client'

import Image from 'next/image'
import { useState } from 'react'

type Props = { src: string; nombre: string }

export function FotoEmpleado({ src, nombre }: Props) {
  const [fallo, setFallo] = useState(false)

  if (fallo) {
    const iniciales = nombre.split(' ').slice(0, 2).map((p) => p[0]).join('')
    return (
      <div className="flex size-24 items-center justify-center rounded-full bg-slate-300 text-2xl font-bold text-slate-600">
        {iniciales}
      </div>
    )
  }

  return (
    <Image
      src={src}
      alt={`Foto de ${nombre}`}
      width={400}
      height={400}
      sizes="96px"
      className="size-24 rounded-full"
      onError={() => setFallo(true)}
    />
  )
}

Fíjate en sizes="96px": la foto se ve a 96 px (size-24). Con eso, Next genera un srcSet con anchos de imageSizes (96, 128, 256…) y el navegador descarga una foto de 96 o 256 px en vez de la de 400.

7.3. Imágenes de servidores de tu red: el bloqueo de IP locales 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 el optimizador descarga imágenes de cualquier servidor que cumpla remotePatterns, aunque esté en localhost o en tu red privada. En la 16 rechaza las IP privadas y responde 400, salvo que actives images.dangerouslyAllowLocalIP. Si usas Next.js 15: no tienes esa protección ni la opción para desactivarla; limita remotePatterns al máximo para no exponer servicios internos.

Arranca la página del equipo con la configuración anterior en Next.js 16 y verás esto:

Página del equipo en Next.js 16 con las fotos sustituidas por iniciales porque el optimizador ha bloqueado el servidor local

Figura 3. En Next.js 16 las fotos de localhost no llegan: el optimizador responde 400 y onError muestra las iniciales.

En la terminal donde corre Next aparece el motivo:

⨯ upstream image http://127.0.0.1:3407/fotos/empleados/100.png hostname resolved to private IP ["127.0.0.1"] If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.

(La captura y el mensaje son del laboratorio, que usa otros puertos; en tu equipo verás localhost:4000.)

¿Por qué bloquea Next.js 16 las IP locales? Por un ataque llamado SSRF (Server-Side Request Forgery, falsificación de peticiones del lado del servidor). Tu servidor Next suele estar en una red con otros servicios que no son públicos: una base de datos con panel web, un servicio de métricas, el servicio de metadatos de la nube (en AWS, la famosa 169.254.169.254, que entrega credenciales). Desde internet nadie llega a ellos, pero tu servidor sí. Si el optimizador descargara cualquier URL, un atacante podría pedirle “tráeme http://169.254.169.254/...” y usarlo de puente. Con el bloqueo, el optimizador resuelve el nombre del host y, si apunta a una IP privada, se niega.

El bloqueo se aplica también en next dev: comprobado, la misma petición da 400 en desarrollo.

Tienes tres salidas, por orden de preferencia:

  1. En producción, usa un servidor público (un CDN, un bucket): el problema desaparece solo, porque no es una IP privada.
  2. Permite las IP locales solo en desarrollo, que es donde tienes el servidor de prácticas:

    // next.config.ts
    const nextConfig: NextConfig = {
      images: {
        remotePatterns: [new URL(`${FOTOS}/fotos/**`)],
        dangerouslyAllowLocalIP: process.env.NODE_ENV === 'development',
      },
    }
    

    Comprobado: con next dev, la misma foto pasa a responder 200. En next build y next start sigue bloqueada.

  3. Desactiva la optimización de esas imágenes con unoptimized (sección 9.2): el navegador las pide directamente al servidor de fotos, sin pasar por el tuyo. Pierdes el redimensionado y el WebP, pero no hay SSRF posible porque tu servidor no descarga nada.

Con la opción 2 activada, la página queda como debe:

Página del equipo con las ocho fotos de los empleados cargadas correctamente

Figura 4. La misma página con dangerouslyAllowLocalIP activado: las fotos llegan optimizadas.

⚠️ Importante: El prefijo dangerously no es decorativo. Solo actívala en producción si de verdad sirves imágenes desde tu red privada (por ejemplo, un servidor de ficheros interno detrás de Next) y entiendes el riesgo. En ese caso, deja remotePatterns restringido a ese único host y ruta.

💡 Consejo: Fíjate en que la página no se rompe cuando una imagen falla: onError la sustituye por las iniciales. Es una buena práctica para cualquier imagen remota, porque el servidor de fotos puede caerse o borrar un fichero.

7.4. Redirecciones: como mucho tres 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 el optimizador sigue todas las redirecciones del servidor remoto. En la 16 sigue como máximo 3 por defecto (opción maximumRedirects) y, si hay más, responde 508. Si usas Next.js 15: no hay límite ni opción para ponerlo; comprueba que tus URL remotas no encadenan redirecciones.

Muchos servicios de imágenes responden con una redirección (302) a la URL definitiva. Ojo a un detalle: el destino de la redirección no tiene que cumplir remotePatterns. Por eso conviene limitar cuántas se siguen. En el laboratorio, con un servidor de pruebas cuya URL /redir/N redirige N veces antes de llegar a la foto, y maximumRedirects: 5:

Next.js 16, /redir/5 (5 saltos)    HTTP/1.1 200 OK
Next.js 16, /redir/6 (6 saltos)    HTTP/1.1 508 Loop Detected
Next.js 15, /redir/6 (6 saltos)    HTTP/1.1 200 OK

Y en la terminal de Next.js 16:

⨯ upstream image response had too many redirects http://127.0.0.1:3407/redir/1

Si tu proveedor necesita más saltos, súbelo; si sabes que nunca redirige, ponlo a 0:

images: {
  maximumRedirects: 0, // no seguir ninguna redirección
}

8. Los cambios de next/image en Next.js 16 🔄 16.0

🔄 Cambia en Next.js 16.0. Varios valores por defecto de images son más estrictos: calidades, anchos, caché, cadenas de consulta locales, IP privadas y redirecciones. Si usas Next.js 15: funciona con los valores antiguos, más permisivos; puedes escribir a mano en next.config.ts la configuración de la sección 8.6 para que se comporte como la 16 y la migración no te dé sorpresas.

Ya has visto dos cambios (el bloqueo de IP locales y el límite de redirecciones) y la nueva prop preload. Aquí van los demás, uno por uno, con lo que se ha medido en el laboratorio con el mismo proyecto compilado en las dos versiones.

8.1. Calidades permitidas: solo 75 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, si no defines images.qualities, se acepta cualquier calidad de 1 a 100. En la 16 el valor por defecto es [75]: la prop quality se redondea al valor permitido más cercano y una petición directa con otra q da 400. Si usas Next.js 15: funciona sin configurar nada; si quieres el comportamiento de la 16, añade qualities: [75].

La prop quality (de 1 a 100, por defecto 75) decide cuánto se comprime la imagen. Con la configuración por defecto, esta imagen:

<Image src="/productos/4.png" alt="Calidad 80" width={800} height={800} quality={80} />

genera URL distintas en cada versión:

Next.js 15:  /_next/image?url=%2Fproductos%2F4.png&w=828&q=80 1x, ...&w=1920&q=80 2x
Next.js 16:  /_next/image?url=%2Fproductos%2F4.png&w=828&q=75 1x, ...&w=1920&q=75 2x

En la 16 tu quality={80} se ha convertido en 75 sin avisar en producción. En desarrollo, eso sí, la terminal lo dice claramente:

Image with src "/productos/4.png" is using quality "80" which is not configured in images.qualities [75]. Please update your config to [75, 80].
Read more: https://nextjs.org/docs/messages/next-image-unconfigured-qualities

Y si alguien pide la URL a mano con otra calidad:

Next.js 15:  HTTP/1.1 200 OK
Next.js 16:  HTTP/1.1 400 Bad Request    "q" parameter (quality) of 80 is not allowed

Para usar varias calidades, decláralas:

images: {
  qualities: [50, 75, 90],
}

Con esa lista, quality={90} y quality={50} se respetan, y quality={80} se redondea a 75, el más cercano. Comprobado: una petición con q=90 da 200, y con q=80 sigue dando 400.

📘 Concepto: ¿Por qué cerrar las calidades? Por lo mismo que los anchos: cada combinación distinta de imagen, ancho y calidad es un fichero nuevo en la caché y un trabajo nuevo para la CPU. Con calidades libres, un atacante podía generar 100 versiones de cada imagen y cada ancho.

8.2. El ancho 16 desaparece de imageSizes 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, imageSizes por defecto es [16, 32, 48, 64, 96, 128, 256, 384]; en la 16 se quita el 16. Si usas Next.js 15: tienes el ancho 16 sin hacer nada. Al migrar, si necesitas iconos de 16 px exactos, declara imageSizes: [16, 32, 48, 64, 96, 128, 256, 384].

Una imagen pequeña de 16 × 16:

<Image src="/productos/6.png" alt="Icono" width={16} height={16} />
Next.js 15:  srcSet="...&w=16&q=75 1x, ...&w=32&q=75 2x"
Next.js 16:  srcSet="...&w=32&q=75 1x"

En la 16 el 1x sube a 32, que es el ancho permitido más cercano por arriba, y el 2x coincide con él, así que solo queda una opción. La imagen se ve igual (el navegador la reduce), pero se descarga una de 32 px. La razón del cambio, según la guía de migración, es que casi nadie servía imágenes de 16 px: en una pantalla de densidad 2 hace falta una de 32 para que no se vea borrosa.

8.3. La caché de imágenes dura 4 horas 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, minimumCacheTTL vale 60 segundos; en la 16, 14 400 (4 horas). Si usas Next.js 15: pon minimumCacheTTL: 14400 si quieres la misma duración; en la 16, baja a 60 si necesitas que los cambios de una imagen se vean antes.

Es la cabecera que viste en la sección 4:

Next.js 15:  Cache-Control: public, max-age=60, must-revalidate
Next.js 16:  Cache-Control: public, max-age=14400, must-revalidate

minimumCacheTTL es un mínimo: si el original trae su propia cabecera Cache-Control con un max-age mayor, gana el mayor. El servidor de fotos de la sección 7 envía max-age=86400, y eso es lo que llega al navegador en las dos versiones:

Cache-Control: public, max-age=86400, must-revalidate

¿Por qué el cambio? Las imágenes de public/ y muchas remotas no traen Cache-Control. Con 60 segundos, cada minuto había que volver a comprobarlas y, en plataformas que cobran por uso, eso costaba dinero. Las imágenes cambian poco, así que 4 horas es un valor más razonable.

El inconveniente: no hay forma de invalidar esa caché. Si cambias public/productos/3.png por otra foto, los usuarios pueden ver la vieja hasta 4 horas. Tienes tres soluciones:

  • Cambiar el nombre del fichero (3-v2.png) cada vez que cambia. Es lo que hace la importación estática con el hash.
  • Añadir una versión en la URL (/productos/3.png?v=2), lo que en la 16 exige configurar localPatterns (sección 8.4).
  • Bajar minimumCacheTTL y borrar .next/cache/images al desplegar.

⚠️ Importante: Las imágenes que ya están en la caché conservan la caducidad con la que se guardaron. En el laboratorio, al cambiar minimumCacheTTL de 60 a 14 400 sin borrar la caché, una imagen ya optimizada seguía saliendo con max-age=60, mientras que otra nueva salía con max-age=14400. Si cambias esta opción y no ves el efecto, borra .next/cache/images.

8.4. Imágenes locales con cadena de consulta: localPatterns 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 una imagen local con ? (por ejemplo /ofertas/banner.png?v=2) se optimiza sin más. En la 16 falla al renderizar y el optimizador responde 400, salvo que la permitas con images.localPatterns y su campo search. Si usas Next.js 15: funciona sin configurar; localPatterns existe y puedes usarlo igual para restringir rutas.

Supón que el banner de ofertas cambia cada semana y, para saltarte la caché, le añades una versión:

<Image src="/ofertas/banner.png?v=2" alt="Oferta de la semana" width={800} height={800} />

En Next.js 16, sin configuración, el componente lanza este error al renderizar:

Image with src "/ofertas/banner.png?v=2" is using a query string which is not configured in images.localPatterns.
Read more: https://nextjs.org/docs/messages/next-image-unconfigured-localpatterns

y el optimizador rechaza la URL:

Next.js 15:  HTTP/1.1 200 OK
Next.js 16:  HTTP/1.1 400 Bad Request    "url" parameter is not allowed

La causa es que, sin localPatterns, Next.js 16 usa por defecto [{ pathname: '**', search: '' }]: cualquier ruta, pero sin cadena de consulta. Así se evita que alguien genere miles de entradas en la caché con ?a=1, ?a=2…

La solución es declarar qué rutas y qué consultas aceptas:

images: {
  localPatterns: [
    { pathname: '/productos/**', search: '' },   // productos, sin consulta
    { pathname: '/ofertas/**', search: '?v=2' }, // ofertas, solo con ?v=2
  ],
}

Medido en Next.js 16 con esa configuración:

/ofertas/banner.png?v=2    200 OK
/ofertas/banner.png?v=3    400 "url" parameter is not allowed
/productos/4.png?v=2       400 "url" parameter is not allowed
/productos/4.png           200 OK

search exige una coincidencia exacta, no admite comodines. Si la versión cambia a menudo, es más cómodo cambiar el nombre del fichero que actualizar la configuración.

💡 Consejo: En cuanto defines localPatterns, todo lo que no esté en la lista queda prohibido, también las rutas sin consulta. Si añades /ofertas/** y olvidas /productos/**, las fotos de productos dejan de funcionar. Las importaciones estáticas no te preocupan: Next añade solo su ruta (/_next/static/media/**) a la lista.

8.5. next/legacy/image y images.domains

Dos avisos más de la guía de migración a la 16, que no cambian el comportamiento pero conviene conocer:

  • next/legacy/image, el componente antiguo (anterior a Next.js 13) que algunos proyectos mantenían para no migrar, está obsoleto. Cámbialo por next/image.
  • images.domains está obsoleta desde la 14 (sección 7.1). Cámbiala por remotePatterns.

8.6. Resumen y configuración “como la 16” para Next.js 15

Opción o prop Next.js 15 Next.js 16 Qué pasa si no haces nada al migrar
preload No existe (usa priority) Nueva priority sigue funcionando, obsoleta
qualities Todas (sin lista) [75] Tus quality={90} pasan a 75
imageSizes Empieza en 16 Empieza en 32 Los iconos de 16 px se sirven a 32
minimumCacheTTL 60 s 14 400 s Los cambios de imagen tardan más en verse
localPatterns Sin restricción Sin cadena de consulta Las imágenes con ?v= fallan
IP privadas Permitidas Bloqueadas (dangerouslyAllowLocalIP) Las imágenes de servidores internos dan 400
Redirecciones Ilimitadas 3 (maximumRedirects) Cadenas largas dan 508

Si sigues en Next.js 15 y quieres adelantarte, esta configuración reproduce los valores por defecto de la 16 que existen en la 15. Se ha comprobado en Next.js 15.5 (sin recompilar, solo reiniciando next start): q=80 da 400, w=16 da 400, ?v=2 da 400 y una imagen nueva sale con max-age=14400.

// next.config.ts en Next.js 15
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    qualities: [75],
    imageSizes: [32, 48, 64, 96, 128, 256, 384],
    minimumCacheTTL: 14400,
    localPatterns: [{ pathname: '/**', search: '' }],
  },
}

export default nextConfig

El bloqueo de IP privadas y el límite de redirecciones no se pueden reproducir en la 15, porque sus opciones no existen.


9. Otras opciones que conviene conocer

9.1. Un cargador propio: loader y loaderFile

Si tus imágenes ya están en un servicio que las redimensiona (Cloudinary, Imgix, un CDN con transformaciones), no tiene sentido que tu servidor Next lo haga otra vez. Con un cargador (loader) le dices a next/image cómo construir la URL de cada ancho, y el navegador la pide directamente a ese servicio:

// app/pruebas/ImagenCdn.tsx
'use client'

import Image, { type ImageLoaderProps } from 'next/image'

// Un cargador propio: las imágenes las redimensiona un CDN externo, no Next
function cargadorCdn({ src, width, quality }: ImageLoaderProps) {
  return `https://cdn.empresa.example${src}?ancho=${width}&calidad=${quality ?? 75}`
}

export function ImagenCdn() {
  return (
    <Image
      loader={cargadorCdn}
      src="/productos/7.png"
      alt="Imagen servida por un CDN"
      width={400}
      height={400}
      sizes="200px"
    />
  )
}

El srcSet generado ya no apunta a /_next/image (anchos abreviados):

https://cdn.empresa.example/productos/7.png?ancho=32&calidad=75 32w,
https://cdn.empresa.example/productos/7.png?ancho=48&calidad=75 48w,
...
https://cdn.empresa.example/productos/7.png?ancho=3840&calidad=75 3840w

Next sigue aportando el srcSet, sizes, loading="lazy" y las dimensiones, pero el trabajo de redimensionar lo hace otro.

Dos detalles importantes:

  • La prop loader es una función, y las funciones no viajan del servidor al cliente. Por eso el componente que la usa lleva 'use client'.
  • Si todas tus imágenes van al mismo servicio, no pases loader en cada una: declara un fichero de cargador global en next.config.ts.
// next.config.ts
images: {
  loader: 'custom',
  loaderFile: './lib/cargador-imagenes.ts',
}
// lib/cargador-imagenes.ts
'use client'

export default function cargador({ src, width, quality }: { src: string; width: number; quality?: number }) {
  return `https://cdn.empresa.example${src}?ancho=${width}&calidad=${quality ?? 75}`
}

9.2. unoptimized: sin optimizar

Con unoptimized, next/image deja de usar el optimizador: el src se usa tal cual y no hay srcSet. Mantienes las ventajas de reservar el hueco y la carga perezosa.

<Image src="/logo.svg" alt="EMPRESA" width={120} height={40} unoptimized />

Úsalo cuando:

  • La imagen es pequeña y ya está optimizada, o es un GIF animado.
  • Es un SVG. Next no optimiza SVG por defecto (es un formato vectorial, no gana nada al redimensionarlo) y, cuando el src acaba en .svg, activa unoptimized solo.
  • La imagen necesita autenticación: el optimizador no reenvía las cookies ni las cabeceras del usuario al descargar el original.
  • Haces una exportación estática (output: 'export', módulo 23), donde no hay servidor que optimice.

Para desactivar la optimización en todo el proyecto: images: { unoptimized: true }.

⚠️ Importante: Existe la opción dangerouslyAllowSVG: true para que el optimizador procese SVG. Un SVG puede contener <script>, así que, si la activas, configura también contentDispositionType: 'attachment' y contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", como pide la documentación. En la práctica, casi nunca hace falta: usa unoptimized con los SVG.

9.3. Dirección de arte con getImageProps

A veces no basta con la misma imagen a distintos tamaños: en móvil quieres otra imagen, por ejemplo un recorte vertical. En HTML eso se hace con <picture> y varios <source>. next/image no genera <picture>, pero la función getImageProps te da las props que calcularía, para que las uses donde quieras:

import { getImageProps } from 'next/image'

export function Cartel() {
  const comunes = { alt: 'Cartel de rebajas', sizes: '100vw' }
  const {
    props: { srcSet: escritorio },
  } = getImageProps({ ...comunes, src: '/productos/1.png', width: 800, height: 800 })
  const {
    props: { srcSet: movil, ...resto },
  } = getImageProps({ ...comunes, src: '/productos/2.png', width: 800, height: 800 })

  return (
    <picture>
      <source media="(min-width: 768px)" srcSet={escritorio} />
      <source media="(max-width: 767px)" srcSet={movil} />
      <img {...resto} className="w-64" />
    </picture>
  )
}

El HTML generado tiene dos <source>, cada uno con su srcSet de /_next/image (640w, 750w, 828w…), y una <img> con el resto de atributos (alt, loading, width, height, src). Es un Server Component: getImageProps no usa hooks y funciona en los dos lados.

⚠️ Importante: No uses placeholder con getImageProps. El componente Image quita el fondo borroso cuando la imagen termina de cargar, pero con getImageProps no hay componente que lo haga, y el placeholder se quedaría para siempre.

9.4. La caché en disco: maximumDiskCacheSize y maximumResponseBody

Dos opciones más, que existen tanto en Next.js 16 como en la última 15.5 (se han incorporado en parches de las dos ramas):

  • maximumDiskCacheSize: tamaño máximo en bytes de .next/cache/images. Por defecto, Next mira al arrancar el espacio libre y usa la mitad. Cuando se llena, borra las imágenes usadas hace más tiempo. Con 0, desactiva la caché en disco.
  • maximumResponseBody: tamaño máximo de una imagen original remota. Por defecto, 50 MB. Si sabes que tus imágenes son pequeñas, bájalo para proteger servidores con poca memoria.
images: {
  maximumDiskCacheSize: 500_000_000, // 500 MB
  maximumResponseBody: 5_000_000,    // 5 MB por imagen original
}

10. Fuentes: un repaso y un caso nuevo

En el módulo 06 viste next/font: descarga las fuentes de Google (o carga las tuyas con next/font/local) al compilar, las sirve desde tu dominio, y ajusta una fuente de reserva para que el texto no salte cuando llega la definitiva. Todo eso sigue igual y no se repite aquí.

Hay un caso que next/font no cubre: dibujar texto dentro de una imagen generada con ImageResponse (sección 14). Allí no hay CSS ni navegador; la imagen se dibuja en el servidor, y la fuente hay que pasarla como datos binarios. Lo verás con la tipografía Poppins en la imagen de cada producto. Las reglas básicas:

  • Solo se admiten ttf, otf y woff (no woff2). Mejor ttf u otf, que se procesan más rápido.
  • Usa fuentes estáticas (un fichero por grosor, como Poppins-Bold.ttf), no variables.
  • Lee el fichero una vez, fuera de la función, para no repetirlo en cada imagen.

Puedes descargar Poppins de su repositorio en Google Fonts:

mkdir assets
curl -L -o assets/Poppins-Bold.ttf https://github.com/google/fonts/raw/main/ofl/poppins/Poppins-Bold.ttf

11. Scripts de terceros con next/script

11.1. El problema

Analítica, chat de soporte, mapas, botones de redes sociales, pasarelas de pago… casi todas las webs cargan código de terceros. Si lo pones con un <script src="..."> normal en el <head>, el navegador lo descarga y ejecuta antes de seguir con la página, y tu LCP sufre por un código que ni siquiera es tuyo.

next/script te deja decidir cuándo se carga cada uno, con la prop strategy:

strategy Cuándo se carga Para qué
beforeInteractive Antes de cualquier código de Next, insertado en el HTML inicial Lo imprescindible antes de hidratar: detección de bots, gestor de consentimiento de cookies. Solo en el layout raíz
afterInteractive (por defecto) Pronto, después de que la página empiece a hidratarse Analítica, gestores de etiquetas
lazyOnload Cuando el navegador está libre, tras cargar todo Chats, widgets de redes sociales, lo que puede esperar
worker En un web worker Experimental y, según la documentación, no funciona con el App Router. No lo uses

11.2. Un script en línea en el layout

En la tienda, un pequeño script de analítica que se ejecuta en todas las páginas:

// app/layout.tsx (fragmento)
import Script from 'next/script'

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body>
        {children}
        <Script id="analitica" strategy="afterInteractive">
          {`window.analitica = { inicio: Date.now() }; console.log('Analítica lista')`}
        </Script>
      </body>
    </html>
  )
}

⚠️ Importante: Los scripts en línea necesitan id. Next lo usa para saber que es el mismo script y no ejecutarlo dos veces cuando navegas entre páginas.

Al estar en un layout, Next garantiza que se carga una sola vez, aunque navegues entre las páginas que comparten ese layout.

11.3. Un script externo con onLoad

El chat de soporte es un script de un proveedor que, al cargarse, crea un objeto global con el que hay que iniciarlo. Para simularlo, crea public/scripts/chat-soporte.js:

// Simula el script de un proveedor externo de chat
window.chatSoporte = {
  iniciar(opciones) {
    console.log('Chat iniciado')
    return 'Chat listo: ' + opciones.saludo
  },
}

Y el componente que lo carga:

// app/pruebas/ChatSoporte.tsx
'use client'

import Script from 'next/script'
import { useState } from 'react'

declare global {
  interface Window {
    chatSoporte?: { iniciar: (opciones: { saludo: string }) => string }
  }
}

export function ChatSoporte() {
  const [estado, setEstado] = useState('Cargando chat...')

  return (
    <>
      <p id="estado-chat">{estado}</p>
      <Script
        src="/scripts/chat-soporte.js"
        strategy="lazyOnload"
        onLoad={() => {
          const texto = window.chatSoporte?.iniciar({ saludo: '¿En qué podemos ayudarte?' })
          setEstado(texto ?? 'El chat no se ha iniciado')
        }}
        onError={() => setEstado('No se pudo cargar el chat')}
      />
    </>
  )
}

onLoad y onError son funciones, así que el componente es de cliente. El bloque declare global le enseña a TypeScript que window puede tener una propiedad chatSoporte.

Abriendo la página con un navegador automatizado (Playwright), esto es lo que se registra en la consola y lo que queda en el párrafo:

consola: Analítica lista
consola: Chat iniciado
estado: Chat listo: ¿En qué podemos ayudarte?

Y en el DOM, Next marca cada script con su estrategia:

<script id="analitica" data-nscript="afterInteractive">window.analitica = { ... }</script>
<script src="/scripts/chat-soporte.js" data-nscript="lazyOnload"></script>

💡 Consejo: onLoad se ejecuta una sola vez, cuando el script termina de cargar. Si necesitas ejecutar algo cada vez que el componente se monta (por ejemplo, reiniciar un mapa al volver a la página), usa onReady, que se llama tras la primera carga y en cada montaje posterior.

💡 Consejo: Pon los scripts en la página o el layout que los necesita, no en el layout raíz “por si acaso”. Un mapa que solo aparece en “Contacto” no tiene por qué cargarse en el catálogo.


12. La Metadata API: el objeto metadata

12.1. Qué son los metadatos y quién los lee

Los metadatos son la información sobre la página que va en el <head>: el <title>, las etiquetas <meta> y algunos <link>. No se ven dentro de la página, pero los leen:

  • El navegador: el título de la pestaña, el icono, el color de la barra en móviles.
  • Los buscadores: el título y la descripción del resultado, la URL canónica, si deben indexar o no.
  • Las redes sociales y las aplicaciones de mensajería: la tarjeta que aparece al pegar un enlace, con su imagen, título y descripción. Usan el protocolo Open Graph (og:...), creado por Facebook, y las etiquetas de X/Twitter (twitter:...).

En una aplicación React normal (SPA), el <head> es el mismo para todas las rutas: el servidor envía siempre el mismo index.html. Los buscadores modernos ejecutan JavaScript, pero las redes sociales y las aplicaciones de mensajería no: leen el HTML tal cual llega. Por eso en una SPA, compartir la ficha de un producto muestra la tarjeta genérica de la tienda. En Next, cada ruta tiene su propio HTML con sus propios metadatos desde el servidor.

12.2. metadata estático en layouts y páginas

Para definir metadatos, exporta un objeto llamado metadata desde un layout.tsx o un page.tsx. El tipo Metadata te da autocompletado para todos los campos:

// app/layout.tsx
import type { Metadata, Viewport } from 'next'
import Link from 'next/link'
import Script from 'next/script'
import './globals.css'

export const metadata: Metadata = {
  metadataBase: new URL(process.env.SITIO_URL ?? 'http://localhost:3001'),
  title: {
    template: '%s | Tienda EMPRESA',
    default: 'Tienda EMPRESA',
  },
  description: 'Catálogo de informática, electrónica y oficina de EMPRESA.',
  applicationName: 'Tienda EMPRESA',
  keywords: ['informática', 'electrónica', 'oficina'],
  authors: [{ name: 'EMPRESA S.A.' }],
  openGraph: {
    type: 'website',
    locale: 'es_ES',
    siteName: 'Tienda EMPRESA',
  },
  twitter: { card: 'summary_large_image' },
}

export const viewport: Viewport = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#3f51b5' },
    { media: '(prefers-color-scheme: dark)', color: '#1a237e' },
  ],
}

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="min-h-screen bg-slate-50 text-slate-800">
        <header className="flex gap-6 bg-indigo-900 px-6 py-3 text-white">
          <Link href="/" className="font-bold">Tienda EMPRESA</Link>
          <Link href="/catalogo">Catálogo</Link>
          <Link href="/equipo">Equipo</Link>
        </header>
        {children}
        <Script id="analitica" strategy="afterInteractive">
          {`window.analitica = { inicio: Date.now() }; console.log('Analítica lista')`}
        </Script>
      </body>
    </html>
  )
}

⚠️ Importante: metadata y generateMetadata solo se admiten en Server Components, nunca en un fichero con 'use client'. Si tu página necesita ser de cliente, deja page.tsx como Server Component con los metadatos y mueve la parte interactiva a un componente hijo.

Y en la portada, una página que sobrescribe el título:

// app/page.tsx (fragmento)
export const metadata: Metadata = {
  title: { absolute: 'Tienda EMPRESA: informática, electrónica y oficina' },
  alternates: { canonical: '/' },
}

Este es el <head> que genera Next para la portada, sin los scripts ni las hojas de estilo:

<meta charSet="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#3f51b5" media="(prefers-color-scheme: light)"/>
<meta name="theme-color" content="#1a237e" media="(prefers-color-scheme: dark)"/>
<title>Tienda EMPRESA: informática, electrónica y oficina</title>
<meta name="description" content="Catálogo de informática, electrónica y oficina de EMPRESA."/>
<meta name="application-name" content="Tienda EMPRESA"/>
<meta name="author" content="EMPRESA S.A."/>
<link rel="manifest" href="/manifest.webmanifest"/>
<meta name="keywords" content="informática,electrónica,oficina"/>
<link rel="canonical" href="http://localhost:3001"/>
<meta property="og:title" content="Tienda EMPRESA: informática, electrónica y oficina"/>
<meta property="og:description" content="Catálogo de informática, electrónica y oficina de EMPRESA."/>
<meta property="og:site_name" content="Tienda EMPRESA"/>
<meta property="og:locale" content="es_ES"/>
<meta property="og:image" content="http://localhost:3001/opengraph-image?472f79d58e26d815"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:alt" content="Tienda EMPRESA: informática, electrónica y oficina"/>
<meta property="og:type" content="website"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="Tienda EMPRESA: informática, electrónica y oficina"/>
<meta name="twitter:description" content="Catálogo de informática, electrónica y oficina de EMPRESA."/>
<meta name="twitter:image" content="http://localhost:3001/opengraph-image?472f79d58e26d815"/>
<link rel="icon" href="/icon?4f99ed893aaeb623" type="image/png" sizes="32x32"/>
<link rel="apple-touch-icon" href="/apple-icon.png?apple-icon.148kquxcwzjdw.png" sizes="180x180" type="image/png"/>

(En el laboratorio, SITIO_URL apuntaba a otro puerto; aquí se muestra con el 3001 que usarás tú. Algunas etiquetas twitter:image:* se han omitido.)

Hay varias cosas que Next ha hecho por su cuenta y conviene entender:

  • Las dos primeras etiquetas (charset y viewport) se añaden siempre, aunque no definas nada.
  • og:title, og:description, twitter:title y twitter:description no estaban en openGraph ni en twitter: Next los ha copiado del title y la description de la página.
  • og:image, los iconos y el manifiesto salen de ficheros especiales que verás en las secciones 14 y 15. No los has escrito en metadata.
  • Las URL son absolutas (http://localhost:3001/...) gracias a metadataBase.

12.3. El título: default, template y absolute

El campo title admite un texto o un objeto con tres claves:

Clave Dónde tiene sentido Efecto
default En un layout Título para las rutas hijas que no definan el suyo
template En un layout Plantilla para los títulos de las rutas hijas: %s se sustituye por su título
absolute En cualquier sitio Título exacto, ignorando la plantilla del layout padre

Con el layout de la tienda:

Ruta title de la página <title> resultante
/catalogo 'Catálogo' Catálogo \| Tienda EMPRESA
/catalogo/3 'Portátil Básico 15' (de generateMetadata) Portátil Básico 15 \| Tienda EMPRESA
/ { absolute: 'Tienda EMPRESA: informática, ...' } Tienda EMPRESA: informática, electrónica y oficina
Una página sin title (nada) Tienda EMPRESA

⚠️ Importante: La plantilla se aplica a los segmentos hijos, no al mismo segmento donde se define. Si pones title: { template: '%s | Tienda', default: 'Tienda' } en app/layout.tsx, la portada app/page.tsx sin título propio recibe el default, pero si le pones title: 'Inicio', sale Inicio | Tienda. Y en un page.tsx, template no tiene efecto, porque una página no tiene hijos.

12.4. metadataBase y las URL relativas

Open Graph y las URL canónicas exigen URL absolutas (con https://dominio). Escribirlas enteras en cada página es incómodo y se rompe al cambiar de entorno. Con metadataBase en el layout raíz, puedes usar rutas relativas en todos los campos de URL, y Next las completa:

metadataBase: new URL(process.env.SITIO_URL ?? 'http://localhost:3001'),
// En una página
alternates: { canonical: '/catalogo/3' }
// En el HTML
// <link rel="canonical" href="http://localhost:3001/catalogo/3"/>

Lo habitual es leer la URL de una variable de entorno (SITIO_URL=https://tienda.empresa.es en producción), para que cada entorno genere sus propias URL. Las variables de entorno se tratan a fondo en el módulo 13.

💡 Consejo: Define metadataBase siempre en el layout raíz y nunca dejes en el código una URL de desarrollo como localhost: las redes sociales reciben la URL de la imagen tal cual y, si apunta a localhost, la tarjeta sale sin imagen.

12.5. Los campos más útiles

Campo Ejemplo Para qué
title 'Catálogo' Pestaña y resultado de búsqueda
description 'Todos los productos...' Texto bajo el título en Google. Unos 150-160 caracteres
alternates.canonical '/catalogo/3' La URL “oficial”, para que /catalogo/3?ref=correo no cuente como página distinta
alternates.languages { 'en-US': '/en/catalogo' } Versiones en otros idiomas (módulo 19)
openGraph { title, description, url, images, type, locale, siteName } Tarjeta al compartir en redes y mensajería
twitter { card: 'summary_large_image' } Tarjeta de X/Twitter. Si falta, X usa Open Graph
robots { index: false, follow: true } Pedir a los buscadores que no indexen esta página
icons { icon: '/icono.png' } Iconos, si no usas los ficheros especiales
manifest '/manifest.webmanifest' Manifiesto de la aplicación, si no usas manifest.ts
verification { google: 'código' } Verificar la propiedad del sitio en Google Search Console
other { 'mi-meta': 'valor' } Cualquier <meta name> que no tenga campo propio

Un ejemplo de robots en una página de gestión interna que no debe aparecer en Google:

export const metadata: Metadata = {
  title: 'Gestión de pedidos',
  robots: { index: false, follow: false },
}
// <meta name="robots" content="noindex, nofollow"/>

⚠️ Importante: robots: { index: false } no protege nada. Es una petición educada a los buscadores, y la página sigue siendo accesible para quien conozca la URL. Las zonas privadas se protegen con autenticación (módulo 17).

12.6. viewport: un export aparte

El color de la barra del navegador (themeColor), el esquema de color (colorScheme) y la configuración del viewport no van en metadata, sino en un export propio llamado viewport (o una función generateViewport):

export const viewport: Viewport = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#3f51b5' },
    { media: '(prefers-color-scheme: dark)', color: '#1a237e' },
  ],
}

Están separados desde Next.js 14 porque el viewport afecta a cómo se pinta la página desde el primer momento, y no puede esperar a que se resuelvan los demás metadatos, que pueden depender de datos (sección 13.4). Los campos themeColor, colorScheme y viewport dentro de metadata están obsoletos desde entonces: si los ves en un tutorial, muévelos a viewport.

12.7. Lo que no hay que hacer: <head> a mano

En el App Router no escribas <head> con <title> dentro del layout, como harías en HTML, ni uses next/head (que es del Pages Router, módulo 24). Las etiquetas que pongas así no se fusionan con las de la Metadata API y acabarás con títulos duplicados. Lo único que puedes escribir a mano, si hace falta, es lo que la Metadata API no cubre, y para la mayoría de casos existe other.


13. Metadatos que dependen de datos: generateMetadata

13.1. La ficha de producto

El objeto metadata es fijo. En la ficha de producto, el título y la descripción dependen del producto, que hay que pedir a la API. Para eso exportas una función asíncrona generateMetadata, que recibe las mismas params y searchParams que la página:

// app/catalogo/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { openGraphBase } from '@/lib/metadatos'
import { euros, obtenerProducto, obtenerProductos } from '@/lib/tienda'

export async function generateStaticParams() {
  const productos = await obtenerProductos()
  return productos.map((p) => ({ id: String(p.productoId) }))
}

export async function generateMetadata(
  { params }: PageProps<'/catalogo/[id]'>,
  parent: ResolvingMetadata,
): Promise<Metadata> {
  const { id } = await params
  const producto = await obtenerProducto(id)
  if (!producto) return { title: 'Producto no encontrado' }

  const anteriores = (await parent).openGraph?.images ?? []
  return {
    title: producto.nombre,
    description: producto.descripcion,
    alternates: { canonical: `/catalogo/${id}` },
    openGraph: {
      ...openGraphBase,
      title: producto.nombre,
      description: producto.descripcion,
      url: `/catalogo/${id}`,
      images: [...anteriores],
    },
  }
}

export default async function FichaProducto({ params }: PageProps<'/catalogo/[id]'>) {
  const { id } = await params
  const producto = await obtenerProducto(id)
  if (!producto) notFound()

  return (
    <main className="grid gap-6 p-6 md:grid-cols-2">
      <Image
        src={`/productos/${producto.productoId}.png`}
        alt={producto.nombre}
        width={800}
        height={800}
        sizes="(max-width: 768px) 100vw, 50vw"
        preload
        className="w-full rounded-lg"
      />
      <div>
        <h1 className="text-3xl font-bold">{producto.nombre}</h1>
        <p className="mt-2">{producto.descripcion}</p>
        <p className="mt-4 text-2xl text-indigo-700">{euros.format(producto.precio)}</p>
      </div>
    </main>
  )
}

Fíjate en estos puntos:

  • params es una promesa y hay que esperarla con await, igual que en la página (módulo 04). PageProps<'/catalogo/[id]'> es el tipo global que genera Next para esa ruta.
  • El segundo parámetro, parent, es una promesa con los metadatos ya resueltos de los segmentos superiores. Sirve para ampliar en vez de sustituir, como aquí con las imágenes de Open Graph del padre.
  • obtenerProducto(id) se llama dos veces, en generateMetadata y en la página. No es un problema: la petición se hace una sola vez, como verás en la sección 13.3.
  • Si el producto no existe, generateMetadata devuelve un título genérico y la página llama a notFound().

Este es el <head> de /catalogo/3 (solo las etiquetas relevantes):

<title>Portátil Básico 15 | Tienda EMPRESA</title>
<meta name="description" content="Portátil de 15 pulgadas para ofimática"/>
<link rel="canonical" href="http://localhost:3001/catalogo/3"/>
<meta property="og:title" content="Portátil Básico 15"/>
<meta property="og:description" content="Portátil de 15 pulgadas para ofimática"/>
<meta property="og:url" content="http://localhost:3001/catalogo/3"/>
<meta property="og:site_name" content="Tienda EMPRESA"/>
<meta property="og:locale" content="es_ES"/>
<meta property="og:image" content="http://localhost:3001/catalogo/3/opengraph-image?01d0b02243aea0b3"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:alt" content="Ficha de producto de la Tienda EMPRESA"/>
<meta property="og:type" content="website"/>

Observa que og:image no es la imagen del padre que pasaste en images, sino la de catalogo/[id]/opengraph-image.tsx. Las imágenes generadas por ficheros especiales tienen prioridad sobre las del objeto (sección 14).

📘 Concepto: Si la ruta /catalogo/999 no existe, en Next.js 16 con Cache Components la respuesta llega con código 200 y una etiqueta <meta name="robots" content="noindex"/>, en lugar de un 404. Es porque la página se envía por streaming y el código HTTP ya ha salido cuando se descubre que no existe. Para los buscadores, noindex tiene el mismo efecto. Lo verás con detalle en el módulo 14.

13.2. La fusión de metadatos: el error del openGraph que desaparece

Una ruta como /catalogo/3 tiene metadatos en varios sitios: app/layout.tsx y app/catalogo/[id]/page.tsx. Next los evalúa en orden, del layout raíz a la página, y los fusiona de forma superficial: cada clave de primer nivel que define un segmento sustituye entera a la del anterior.

Diagrama de la fusión de metadatos entre el layout raíz y la ficha de producto, en el que openGraph se reemplaza completo

Figura 5. La fusión es superficial: si la página define openGraph, el openGraph del layout desaparece entero.

La primera versión de generateMetadata de la tienda no tenía la línea ...openGraphBase. Su <head> tenía og:title, og:description y og:url… y nada más: ni og:site_name, ni og:locale, ni og:type. El layout los definía, pero la página, al escribir su propio openGraph, los borró. twitter, en cambio, se mantuvo, porque la página no lo tocaba.

Es uno de los errores más comunes con la Metadata API, y no da ningún aviso. La solución es sacar los campos comunes a un objeto compartido:

// lib/metadatos.ts
import type { Metadata } from 'next'

// Campos de Open Graph comunes a todas las páginas de la tienda
export const openGraphBase = {
  type: 'website',
  locale: 'es_ES',
  siteName: 'Tienda EMPRESA',
} satisfies Metadata['openGraph']

y repartirlo con el operador de propagación (...openGraphBase) en cada página que defina openGraph, como en la sección anterior. Con esa línea, el <head> de la ficha vuelve a tener og:site_name, og:locale y og:type.

💡 Consejo: satisfies comprueba que el objeto encaja con el tipo Metadata['openGraph'] sin “ensanchar” su tipo. Si escribieras const openGraphBase: Metadata['openGraph'] = {...}, TypeScript diría que puede ser undefined y te obligaría a comprobarlo antes de propagarlo.

13.3. No pedir los datos dos veces

La página y generateMetadata llaman a obtenerProducto(id). ¿Son dos peticiones a la API? No, por dos mecanismos que se suman:

  • React memoriza durante un mismo render las llamadas a fetch con la misma URL y opciones, y la función cache de React permite hacer lo mismo con cualquier función (por ejemplo, una consulta a la base de datos).
  • En este proyecto, además, obtenerProducto usa 'use cache', que guarda el resultado entre peticiones.

Las dos cosas se explican a fondo en el módulo 08 y el módulo 09. Por ahora, quédate con la idea: puedes llamar a la misma función de datos desde generateMetadata y desde la página sin pagar dos veces.

13.4. Metadatos en streaming

Si generateMetadata tarda (porque la API es lenta), ¿se queda la página en blanco esperando? En las páginas que se generan en cada petición, no: Next envía primero el contenido y añade los metadatos al final del HTML cuando están listos. Es el streaming de metadatos.

Hay una excepción importante: los robots que no ejecutan JavaScript (los de Facebook, Slack, X…). Next los reconoce por su cabecera User-Agent y, para ellos, espera a tener los metadatos y los pone en el <head>, como siempre. Puedes cambiar esa lista con la opción htmlLimitedBots de next.config.ts, o desactivar el streaming de metadatos para todos con htmlLimitedBots: /.*/.

En las páginas que se prerenderizan al compilar, como las fichas de este proyecto (gracias a generateStaticParams y 'use cache'), no hay streaming: los metadatos se resuelven en next build y van en el <head> desde el principio.

⚠️ Importante: Con Cache Components (la opción cacheComponents, activa por defecto en los proyectos nuevos de Next.js 16.4), generateMetadata sigue las mismas reglas que cualquier componente: si pide datos sin caché y el resto de la página es estático, next build falla y te pide que decidas. Las dos salidas son cachear los datos ('use cache', como en este proyecto) o indicar que la página es dinámica a propósito. Lo verás en el módulo 09.


14. Iconos e imágenes para redes sociales

Algunos metadatos no se escriben en metadata: se crean poniendo un fichero con un nombre especial en la carpeta de la ruta. Next detecta el fichero, lo sirve en una URL y añade la etiqueta correspondiente al <head>.

Diagrama de los ficheros especiales de metadatos, la URL que genera cada uno y la etiqueta que añade al head

Figura 6. Cada fichero especial genera una URL y, en la mayoría de los casos, una etiqueta en el <head>. Las URL llevan un *hash para que el navegador no use una versión antigua de caché.*

14.1. Iconos estáticos: favicon.ico, icon y apple-icon

La forma más simple es copiar una imagen con el nombre adecuado en app/:

Fichero Formatos Etiqueta que genera
app/favicon.ico .ico (solo en la raíz de app/) <link rel="icon" href="/favicon.ico" sizes="any">
app/icon.png .ico, .jpg, .jpeg, .png, .svg <link rel="icon" href="/icon.png?..." type="image/png" sizes="...">
app/apple-icon.png .jpg, .jpeg, .png <link rel="apple-touch-icon" href="/apple-icon.png?..." sizes="...">

sizes="any" aparece cuando el icono es SVG o Next no puede leer su tamaño, como en un .ico con varias resoluciones.

En el proyecto, app/apple-icon.png es un PNG de 180 × 180 px (el tamaño que usa iOS al añadir la web a la pantalla de inicio). Next lee sus dimensiones y genera:

<link rel="apple-touch-icon" href="/apple-icon.png?apple-icon.148kquxcwzjdw.png" sizes="180x180" type="image/png"/>

create-next-app crea un app/favicon.ico. En el proyecto de este módulo se ha borrado y sustituido por un icono generado con código.

14.2. Iconos generados con código: icon.tsx

En lugar de una imagen, icon puede ser un fichero .tsx que exporta una función que devuelve la imagen. Se dibuja con ImageResponse, de next/og, que convierte JSX con estilos en un PNG:

// app/icon.tsx
import { ImageResponse } from 'next/og'

export const size = { width: 32, height: 32 }
export const contentType = 'image/png'

export default function Icono() {
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          background: '#3f51b5',
          color: 'white',
          fontSize: 22,
          fontWeight: 700,
          borderRadius: 6,
        }}
      >
        E
      </div>
    ),
    { ...size },
  )
}
  • size y contentType son exports de configuración: Next los usa para escribir sizes="32x32" y type="image/png" en el <link>.
  • La función se ejecuta al compilar, porque no depende de nada de la petición. En la salida de next build, /icon aparece como ○ (Static).
<link rel="icon" href="/icon?4f99ed893aaeb623" type="image/png" sizes="32x32"/>

14.3. Cómo dibuja ImageResponse: Satori y sus límites

ImageResponse no usa un navegador. Por dentro usa Satori, una biblioteca que interpreta JSX con estilos en línea y lo convierte en SVG, y Resvg, que convierte el SVG en PNG. Es rapidísimo, pero solo entiende un subconjunto de HTML y CSS:

  • Solo flexbox y posicionamiento absoluto. display: grid no funciona.
  • Solo estilos en línea (style={{...}}). No hay className, ni Tailwind, ni hojas de estilo.
  • Todo <div> con más de un hijo debe tener display: flex (o contents, o none). Es el error más frecuente; lo verás en la sección 17.
  • Fuentes: trae una fuente por defecto, y puedes pasar las tuyas en ttf, otf o woff.
  • Tamaño máximo del conjunto (JSX, fuentes, imágenes incrustadas): 500 KB.

💡 Consejo: Para diseñar estas imágenes sin compilar cada vez, usa el playground de Vercel (https://og-playground.vercel.app/), que usa el mismo motor y te muestra el resultado al momento.

14.4. La imagen de Open Graph del sitio: opengraph-image.tsx

Con el mismo sistema, app/opengraph-image.tsx genera la imagen que verán las redes sociales para todo el sitio:

// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const alt = 'Tienda EMPRESA: informática, electrónica y oficina'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default function Imagen() {
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          background: '#1a237e',
          color: 'white',
        }}
      >
        <div style={{ fontSize: 96, fontWeight: 700 }}>Tienda EMPRESA</div>
        <div style={{ fontSize: 40, marginTop: 24, color: '#c5cae9' }}>
          Informática, electrónica y oficina
        </div>
      </div>
    ),
    { ...size },
  )
}
  • 1200 × 630 es el tamaño recomendado para Open Graph: es el que usan Facebook, LinkedIn o WhatsApp para la tarjeta grande.
  • alt se convierte en og:image:alt. No es opcional en la práctica: los lectores de pantalla lo leen al encontrar la tarjeta.
  • Next añade og:image y twitter:image con la misma URL. Si quieres una distinta para X, crea twitter-image.tsx.

Un fichero opengraph-image en una carpeta se aplica a esa ruta y a todas las que cuelgan de ella, salvo que una más profunda tenga el suyo. El de app/ vale para todo el sitio; el de app/catalogo/[id]/ lo sustituye en las fichas.

📘 Concepto: Si tienes una imagen ya hecha, no necesitas código: copia opengraph-image.png (o .jpg) en la carpeta, y opcionalmente un opengraph-image.alt.txt con el texto alternativo. Next lee las dimensiones del fichero y genera las mismas etiquetas.

14.5. Una imagen por producto, con datos y fuente propia 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, la función de opengraph-image, twitter-image, icon y apple-icon recibe params como un objeto normal. En la 16 recibe una promesa (y id, si usas generateImageMetadata, también). Si usas Next.js 15: escribe la firma como ({ params }: { params: { id: string } }) y usa params.id sin await.

Lo más útil de opengraph-image.tsx es que puede leer los parámetros de la ruta y pedir datos. Así, cada producto tiene su propia tarjeta con su nombre y su precio:

// app/catalogo/[id]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { euros, obtenerProducto } from '@/lib/tienda'

export const alt = 'Ficha de producto de la Tienda EMPRESA'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

const poppins = await readFile(join(process.cwd(), 'assets/Poppins-Bold.ttf'))

export default async function Imagen({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const producto = await obtenerProducto(id)

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: 64,
          background: 'linear-gradient(135deg, #1a237e, #3f51b5)',
          color: 'white',
          fontFamily: 'Poppins',
        }}
      >
        <div style={{ fontSize: 36, opacity: 0.8 }}>Tienda EMPRESA</div>
        <div style={{ display: 'flex', flexDirection: 'column' }}>
          <div style={{ fontSize: 80, lineHeight: 1.1 }}>{producto?.nombre ?? 'Producto'}</div>
          <div style={{ fontSize: 36, marginTop: 16, opacity: 0.9 }}>{producto?.descripcion}</div>
        </div>
        <div style={{ fontSize: 56, color: '#ffab91' }}>
          {producto ? euros.format(producto.precio) : ''}
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [{ name: 'Poppins', data: poppins, style: 'normal', weight: 700 }],
    },
  )
}

Este es el resultado real para /catalogo/3/opengraph-image:

Imagen de Open Graph generada para el Portátil Básico 15, con el nombre, la descripción y el precio sobre un degradado azul

Figura 7. La tarjeta del producto 3, generada con ImageResponse y la fuente Poppins. Las tildes y el símbolo del euro se dibujan bien porque la fuente los incluye.

Detalles del código:

  • La fuente se lee una sola vez, con un await en el nivel superior del módulo (fuera de la función). process.cwd() es la raíz del proyecto, así que el fichero está en assets/Poppins-Bold.ttf.
  • fontFamily: 'Poppins' coincide con el name de la fuente en fonts.
  • weight: 700 debe corresponder al fichero: Poppins-Bold.ttf es el grosor 700.
  • euros.format es un Intl.NumberFormat('es-ES', { currency: 'EUR' }): el formato numérico se hace en el servidor, como en cualquier componente.

En la salida de next build, esta ruta aparece como dinámica:

├ ƒ /catalogo/[id]/opengraph-image

Se genera bajo demanda la primera vez que alguien (o un robot de una red social) la pide. En Next.js 15 sale igual, ƒ.

En Next.js 15 se ha comprobado la misma imagen con la firma síncrona de la caja anterior: compila y devuelve un PNG con código 200.

📘 Concepto: Si una ruta necesita varias imágenes (por ejemplo, iconos de varios tamaños desde un mismo fichero), exporta también generateImageMetadata, que devuelve una lista de { id, size, alt, contentType }. Next genera una URL por elemento y llama a tu función con cada id. En Next.js 16 ese id llega como promesa.

14.6. ImageResponse fuera de los ficheros especiales

ImageResponse es una Response normal, así que también puedes usarla en un Route Handler (módulo 12) para generar imágenes a demanda con parámetros de la URL, por ejemplo /api/cartel?texto=Rebajas. La diferencia es que, en ese caso, Next no añade ninguna etiqueta al <head>: tendrás que poner la URL tú en openGraph.images.


15. sitemap, robots y manifest

15.1. sitemap.ts: el mapa del sitio

Un sitemap es un XML con la lista de URL que quieres que los buscadores conozcan. Es especialmente útil para páginas a las que se llega con dificultad navegando, como las fichas de un catálogo grande. En Next, se genera con app/sitemap.ts:

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { obtenerProductos } from '@/lib/tienda'

const SITIO = process.env.SITIO_URL ?? 'http://localhost:3001'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const productos = await obtenerProductos()

  const fijas: MetadataRoute.Sitemap = [
    { url: SITIO, changeFrequency: 'weekly', priority: 1 },
    { url: `${SITIO}/catalogo`, changeFrequency: 'daily', priority: 0.8 },
  ]

  const fichas: MetadataRoute.Sitemap = productos
    .filter((p) => !p.descatalogado)
    .map((p) => ({
      url: `${SITIO}/catalogo/${p.productoId}`,
      lastModified: p.fechaAlta,
      changeFrequency: 'monthly',
      priority: 0.5,
      images: [`${SITIO}/productos/${p.productoId}.png`],
    }))

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

Next lo sirve en /sitemap.xml. El principio de la respuesta real:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>http://localhost:3001</loc>
<changefreq>weekly</changefreq>
<priority>1</priority>
</url>
<url>
<loc>http://localhost:3001/catalogo</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>http://localhost:3001/catalogo/1</loc>
<image:image>
<image:loc>http://localhost:3001/productos/1.png</image:loc>
</image:image>
<lastmod>2024-01-10</lastmod>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
  • Las URL del sitemap deben ser absolutas. Aquí no se aplica metadataBase: por eso se construyen con SITIO.
  • images añade el espacio de nombres image: de Google, que ayuda a que tus fotos aparezcan en Google Imágenes.
  • changeFrequency y priority son orientativos: cada buscador decide si los tiene en cuenta. Lo que más importa es que estén todas las URL y que lastModified sea correcto.

En la salida de next build, el sitemap aparece como ○ /sitemap.xml 15m 1y: es estático, y como los datos vienen de una función con 'use cache', se regenera cada cierto tiempo. Lo entenderás en el módulo 09.

💡 Consejo: Un sitemap admite como mucho 50 000 URL. Si tu catálogo es mayor, exporta además generateSitemaps para partirlo en varios ficheros (/catalogo/sitemap/0.xml, /catalogo/sitemap/1.xml…). Según la guía de migración, en Next.js 16 el id que recibe tu función sitemap en ese caso es una promesa, igual que los params de las imágenes.

15.2. robots.ts: qué pueden rastrear los robots

robots.txt dice a los robots qué partes del sitio pueden rastrear y dónde está el sitemap. Con app/robots.ts:

// app/robots.ts
import type { MetadataRoute } from 'next'

const SITIO = process.env.SITIO_URL ?? 'http://localhost:3001'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/gestion/', '/api/'] },
      { userAgent: 'GPTBot', disallow: '/' },
    ],
    sitemap: `${SITIO}/sitemap.xml`,
  }
}

Respuesta real de /robots.txt:

User-Agent: *
Allow: /
Disallow: /gestion/
Disallow: /api/

User-Agent: GPTBot
Disallow: /

Sitemap: http://localhost:3001/sitemap.xml

Cada regla genera un bloque User-Agent. El segundo bloque impide el paso al rastreador de OpenAI, que usa para entrenar modelos; muchas webs añaden reglas así para los rastreadores de IA.

⚠️ Importante: Igual que noindex, robots.txt es una petición, no una barrera. Los robots maliciosos lo ignoran, y además es público: cualquiera puede leerlo y ver que existe /gestion/. No lo uses para “esconder” nada.

15.3. Directivas no estándar en robots 🆕 16.3

🆕 Desde Next.js 16.3. No existe en Next.js 15: el tipo de las reglas no tiene el campo other y TypeScript lo rechaza ('other' does not exist in type...). Si usas Next.js 15: escribe el fichero estático app/robots.txt a mano con las directivas que necesites, en lugar de robots.ts.

Algunos buscadores entienden directivas que no forman parte del estándar, como Request-Rate (Seznam) o Clean-param (Yandex). Desde la 16.3, cada regla admite un campo other con ellas:

{ userAgent: 'SeznamBot', allow: '/', other: { 'Request-Rate': '10/1m' } },
User-Agent: SeznamBot
Allow: /
Request-Rate: 10/1m

Next copia las claves y los valores tal cual, sin validarlos. Comprueba la sintaxis en la documentación del buscador.

15.4. manifest.ts: la aplicación instalable

El manifiesto (Web App Manifest) es un JSON que describe tu web como aplicación: nombre, iconos, colores y cómo se abre al instalarla en el móvil o el escritorio. Es una de las piezas de una PWA (Progressive Web App).

// app/manifest.ts
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Tienda EMPRESA',
    short_name: 'EMPRESA',
    description: 'Catálogo de informática, electrónica y oficina.',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#3f51b5',
    lang: 'es',
    icons: [
      { src: '/icono-192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icono-512.png', sizes: '512x512', type: 'image/png' },
    ],
  }
}

Next lo sirve en /manifest.webmanifest y añade <link rel="manifest" href="/manifest.webmanifest"/> al <head> de todas las páginas, como viste en la sección 12.2. Los iconos de 192 y 512 px son los que piden los navegadores para instalar la aplicación; aquí están en public/.

📘 Concepto: Un manifiesto no convierte tu web en una PWA completa: para funcionar sin conexión hace falta además un service worker. La documentación local de Next (node_modules/next/dist/docs/01-app/02-guides/progressive-web-apps.md) tiene una guía completa.

15.5. ¿Fichero estático o código?

Todos estos ficheros especiales (icon, apple-icon, opengraph-image, twitter-image, sitemap, robots, manifest) admiten las dos formas:

Forma Ejemplo Cuándo
Estática app/robots.txt, app/icon.png, app/manifest.json El contenido no cambia nunca
Código app/robots.ts, app/icon.tsx, app/manifest.ts Depende de datos, de variables de entorno o quieres tipos

En los dos casos el resultado se sirve en la misma URL. No pongas las dos formas a la vez para el mismo fichero.


16. Caso práctico: la tienda pública de EMPRESA

Ya has visto todas las piezas por separado. Aquí está el proyecto completo, para que puedas montarlo de principio a fin y comprobar cada cosa en tu equipo.

16.1. Estructura

tienda/
├── app/
│   ├── layout.tsx                  metadata global, viewport, Script de analítica
│   ├── page.tsx                    portada: imagen fill + preload + blur
│   ├── icon.tsx                    favicon generado (32 × 32)
│   ├── apple-icon.png              icono de iOS (180 × 180)
│   ├── opengraph-image.tsx         tarjeta social del sitio
│   ├── manifest.ts
│   ├── robots.ts
│   ├── sitemap.ts
│   ├── globals.css
│   ├── catalogo/
│   │   ├── page.tsx                rejilla con sizes y loading
│   │   └── [id]/
│   │       ├── page.tsx            generateMetadata + generateStaticParams
│   │       └── opengraph-image.tsx tarjeta social de cada producto
│   └── equipo/
│       ├── page.tsx                fotos remotas
│       └── FotoEmpleado.tsx        'use client' con onError
├── assets/Poppins-Bold.ttf         fuente para las imágenes generadas
├── imagenes/portada.jpg            imagen importada estáticamente (1600 × 900)
├── lib/
│   ├── metadatos.ts                openGraphBase
│   └── tienda.ts                   acceso a la API
├── public/
│   ├── productos/1.png ... 20.png  fotos de producto (800 × 800)
│   ├── icono-192.png, icono-512.png
│   └── scripts/chat-soporte.js
├── servidor-fotos.mjs              "CDN" de fotos de empleados (puerto 4000)
└── next.config.ts

Las imágenes de producto puedes crearlas con cualquier programa de dibujo, o descargar fotos libres; lo único que importa es que sean de 800 × 800 y se llamen como el productoId.

16.2. La capa de datos 🆕 16.0

🆕 Desde Next.js 16.0. La directiva 'use cache' es estable desde la 16; en Next.js 15 solo existe como experimento, tras la opción experimental.useCache, y sin ella no compila. Si usas Next.js 15: borra las líneas 'use cache'; el proyecto compila igual y las páginas se prerenderizan en next build con los datos de ese momento (comprobado: el catálogo sale como ○ (Static) y las fichas como ● (SSG)).

// lib/tienda.ts
import 'server-only'

const API = process.env.API_URL ?? 'http://localhost:3000/api'
export const FOTOS = process.env.FOTOS_URL ?? 'http://localhost:4000'

export type Producto = {
  productoId: number
  nombre: string
  descripcion: string
  categoriaId: number
  precio: number
  stock: number
  fechaAlta: string
  descatalogado: boolean
}

export type Empleado = {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
}

export async function obtenerProductos(): Promise<Producto[]> {
  'use cache'
  const res = await fetch(`${API}/productos?tamano=100`)
  if (!res.ok) throw new Error(`La API respondió ${res.status}`)
  const pagina = await res.json()
  return pagina.contenido
}

export async function obtenerProducto(id: string): Promise<Producto | null> {
  'use cache'
  const res = await fetch(`${API}/productos/${id}`)
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`La API respondió ${res.status}`)
  return res.json()
}

export async function obtenerEquipo(): Promise<Empleado[]> {
  'use cache'
  const res = await fetch(`${API}/empleados?tamano=8`)
  if (!res.ok) throw new Error(`La API respondió ${res.status}`)
  return (await res.json()).contenido
}

export const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
  • import 'server-only' hace que la compilación falle si algún componente de cliente importa este fichero (módulo 05).
  • Los listados de la API EMPRESA devuelven { contenido, pagina, tamano, total, paginas }; por eso se devuelve pagina.contenido.
  • 'use cache' guarda el resultado de la función. Sin ella, Next.js 16 con Cache Components no puede prerenderizar estas páginas y next build falla con un error de “datos sin caché”. Por ahora tómala como una línea necesaria; el módulo 09 la explica a fondo.

16.3. La configuración

// next.config.ts
import type { NextConfig } from 'next'

const FOTOS = process.env.FOTOS_URL ?? 'http://localhost:4000'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  turbopack: {
    rules: {
      '*.css': { loaders: ['@tailwindcss/turbopack'], as: '*.css' },
    },
  },
  images: {
    remotePatterns: [new URL(`${FOTOS}/fotos/**`)],
    dangerouslyAllowLocalIP: process.env.NODE_ENV === 'development',
  },
}

export default nextConfig

cacheComponents, partialPrefetching y el bloque turbopack son los que pone create-next-app en Next.js 16.4 (módulo 01); no los toques. Lo nuevo de este módulo es images.

💡 Consejo: En Next.js 15 el fichero es más corto: sin cacheComponents, partialPrefetching, turbopack ni dangerouslyAllowLocalIP (que no existen allí). Basta con images: { remotePatterns: [...] }.

// app/catalogo/page.tsx
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { euros, obtenerProductos } from '@/lib/tienda'

export const metadata: Metadata = {
  title: 'Catálogo',
  description: 'Todos los productos de la Tienda EMPRESA.',
  alternates: { canonical: '/catalogo' },
}

export default async function Catalogo() {
  const productos = await obtenerProductos()
  return (
    <main className="p-6">
      <h1 className="mb-4 text-2xl font-bold">Catálogo</h1>
      <ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
        {productos.map((p, i) => (
          <li key={p.productoId} className="rounded-lg bg-white p-3 shadow">
            <Link href={`/catalogo/${p.productoId}`}>
              <Image
                src={`/productos/${p.productoId}.png`}
                alt={p.nombre}
                width={800}
                height={800}
                sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
                loading={i < 4 ? 'eager' : 'lazy'}
                className="aspect-square w-full rounded"
              />
              <h2 className="mt-2 font-semibold">{p.nombre}</h2>
              <p className="text-indigo-700">{euros.format(p.precio)}</p>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

Captura del catálogo de la Tienda EMPRESA con una rejilla de tarjetas de producto con imagen, nombre y precio

Figura 8. El catálogo a 900 px de ancho: tres columnas, sizes le dice al navegador que cada imagen ocupa un tercio.

La portada (app/page.tsx), la ficha (app/catalogo/[id]/page.tsx), las dos opengraph-image.tsx, icon.tsx, sitemap.ts, robots.ts, manifest.ts y la página del equipo son exactamente las que has visto en las secciones anteriores.

16.5. Arrancarlo todo

Necesitas tres terminales:

# Terminal 1: la API EMPRESA, en el puerto 3000
cd empresa-api
node server.mjs

# Terminal 2: el servidor de fotos, en el puerto 4000
cd tienda
node servidor-fotos.mjs

# Terminal 3: Next.js, en el 3001
cd tienda
npm run dev -- -p 3001

Para compilar para producción, la API debe estar en marcha, porque next build prerenderiza el catálogo y las fichas pidiendo los datos. La salida de next build en Next.js 16.4 (del laboratorio):

Route (app)                         Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ○ /apple-icon.png
├ ○ /catalogo                              15m      1y
├   /catalogo/[id]
│ ├ ◐ /catalogo/[id]
│ ├ ○ /catalogo/1                          15m      1y
│ ├ ○ /catalogo/2                          15m      1y
│ └ ◐ [+18 more paths]
├ ƒ /catalogo/[id]/opengraph-image
├ ○ /equipo                                15m      1y
├ ○ /icon
├ ○ /manifest.webmanifest
├ ○ /opengraph-image
├ ○ /robots.txt
└ ○ /sitemap.xml                           15m      1y

Casi todo es estático (○), incluidos el icono, la imagen social del sitio, el manifiesto, robots.txt y el sitemap: se generan una vez al compilar. Las columnas Revalidate y Expire vienen de 'use cache' (módulo 09). Las imágenes sociales de cada producto se generan bajo demanda (ƒ).

16.6. Lista de comprobación

Con npm run build && npm start -- -p 3001 (o con npm run dev -- -p 3001):

  1. Portada. Ver el código fuente (Ctrl+U): el <head> empieza con un <link rel="preload" as="image"> de la portada.
  2. Catálogo. Pestaña Red, filtro Img, recarga: las imágenes son /_next/image?...&w=..., de tipo webp, y las de abajo no se piden hasta que haces scroll.
  3. Ficha. http://localhost:3001/catalogo/3: el título de la pestaña es “Portátil Básico 15 Tienda EMPRESA”.
  4. Tarjeta social. http://localhost:3001/catalogo/3/opengraph-image muestra la imagen de la figura 7.
  5. Equipo. En desarrollo se ven las fotos. En producción, sin dangerouslyAllowLocalIP, salen las iniciales: es el bloqueo de IP locales.
  6. Ficheros especiales. /robots.txt, /sitemap.xml, /manifest.webmanifest e /icon responden.
  7. Cabeceras. curl -I "http://localhost:3001/_next/image?url=%2Fproductos%2F1.png&w=640&q=75" da Cache-Control: public, max-age=14400, must-revalidate.

💡 Consejo: Para ver cómo quedará la tarjeta en las redes sin publicar nada, hay validadores en línea (el de LinkedIn, el de Facebook para desarrolladores), pero necesitan una URL pública. En local, basta con abrir la URL de og:image y revisar el <head>.


17. Errores frecuentes

17.1. “Image with src … is missing required “width” property”

<Image src="/productos/3.png" alt="Portátil" />

Las imágenes de public/ y las remotas necesitan width y height, porque Next no puede leer el fichero en el componente. Solución: añade las dimensiones reales, usa una importación estática o usa fill con un contenedor posicionado.

17.2. La imagen con fill no se ve

Tres causas habituales, por orden de frecuencia:

  1. El padre no tiene altura: un div sin h-... ni aspect-... mide 0 px de alto.
  2. El padre no tiene position: la imagen se coloca respecto a otro antepasado y aparece en otro sitio, o tapando media página.
  3. Un padre flex o grid encoge el contenedor a 0 de ancho.

Solución: className="relative h-64" (o relative aspect-video w-full) en el contenedor directo de la imagen.

17.3. La imagen se ve borrosa

  • Pusiste en width/height el tamaño en pantalla y no el del fichero, y el CSS la estira.
  • sizes dice que ocupa menos de lo que ocupa realmente.
  • En Next.js 16, tu quality={90} se está convirtiendo en 75 porque no está en qualities (sección 8.1). Mira la terminal en desarrollo: lo avisa.

17.4. “hostname … is not configured under images”

Usas una URL remota que no cumple ningún patrón de remotePatterns. Revisa protocolo, host, puerto y ruta: http://localhost:4000 no es lo mismo que http://localhost:4001 ni que https://localhost:4000. Después de cambiar next.config.ts, reinicia el servidor de desarrollo si no recoge el cambio.

17.5. “hostname resolved to private IP” (400)

Next.js 16 bloquea las IP privadas (sección 7.3). Ocurre con localhost, 127.0.0.1, IP de tu red (192.168.x.x, 10.x.x.x) y nombres que se resuelvan a ellas. Solución: dangerouslyAllowLocalIP: process.env.NODE_ENV === 'development' para desarrollo, o unoptimized en esas imágenes.

17.6. “is using a query string which is not configured in images.localPatterns”

Una imagen local con ? en Next.js 16 (sección 8.4). Solución: añade una entrada a localPatterns con search exacto, o quita la consulta y cambia el nombre del fichero.

17.7. Peticiones a /_next/image que dan 400

Mira el cuerpo de la respuesta, que dice qué parámetro falla:

Mensaje Causa
"w" parameter (width) of N is not allowed w no está en deviceSizes ni en imageSizes
"q" parameter (quality) of N is not allowed q no está en qualities
"url" parameter is not allowed No cumple localPatterns o remotePatterns

Esto pasa sobre todo cuando construyes URL de /_next/image a mano o las copias de otra configuración. Deja que next/image las genere.

17.8. El cambio de una imagen no se ve

Has sustituido public/productos/3.png y sigues viendo la vieja. Es la caché: la del navegador (hasta max-age) y la de .next/cache/images. Soluciones: cambia el nombre del fichero, borra .next/cache/images y recarga sin caché (Ctrl+F5). En producción, piensa en esto antes: usa nombres con versión o importaciones estáticas.

17.9. “Expected <div> to have explicit display: flex”

Al compilar la imagen social del equipo, el laboratorio dio este error:

Error occurred prerendering page "/equipo/opengraph-image". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node.

El culpable era esta línea:

<div style={{ fontSize: 32, marginTop: 40, color: '#37474f' }}>
  {equipo.length} personas a tu servicio
</div>

Para JSX, {equipo.length} y ` personas a tu servicio son **dos** hijos: un número y un texto. Satori exige display: flex en cualquier <div>` con más de un hijo. Solución: convertirlo en un solo texto con una plantilla:

<div style={{ fontSize: 32, marginTop: 40, color: '#37474f' }}>
  {`${equipo.length} personas a tu servicio`}
</div>

o añadir display: 'flex' a ese <div>.

17.10. Los metadatos de Open Graph del layout desaparecen

Lo viste en la sección 13.2: si una página define openGraph, sustituye entero al del layout. Solución: un objeto compartido con ...openGraphBase.

17.11. Un <title> que no cambia o sale duplicado

  • Escribiste un <title> a mano dentro de <head> en el layout: quítalo y usa metadata.
  • Exportaste metadata desde un componente que no es layout ni page (por ejemplo, un componente de app/catalogo/Tarjeta.tsx): Next solo lo lee de layouts y páginas.
  • Exportaste a la vez metadata y generateMetadata en el mismo fichero: elige uno.

17.12. next build falla al prerenderizar porque la API no responde

Si el catálogo o el sitemap piden datos a la API EMPRESA, next build necesita la API en marcha, porque genera esas páginas al compilar. Arranca la API antes de compilar.


18. Ejercicios

Ejercicio 1: Un avatar de tamaño fijo

Crea un componente Avatar que muestre una imagen redonda de 48 px (o de 96 px, si se le pasa tamano={96}). Debe descargar una imagen de 48 px en pantallas normales y de 96 px en pantallas de alta densidad, no la de 800 px del fichero original.

Ver solución

Como el tamaño en pantalla es fijo, no hace falta sizes: el srcSet con 1x y 2x es exactamente lo que queremos. La clave es que, para un tamaño fijo, width y height son el tamaño en pantalla: Next calcula el 1x con ese ancho y el 2x con el doble.

// app/ejercicios/Avatar.tsx
import Image from 'next/image'

type Props = { src: string; nombre: string; tamano?: 48 | 96 }

export function Avatar({ src, nombre, tamano = 48 }: Props) {
  return (
    <Image
      src={src}
      alt={`Foto de ${nombre}`}
      width={tamano}
      height={tamano}
      className="rounded-full"
      style={{ width: tamano, height: tamano }}
    />
  )
}

Uso: <Avatar src="/productos/9.png" nombre="Producto nueve" />. HTML generado:

style="color:transparent;width:48px;height:48px"
srcSet="/_next/image?url=%2Fproductos%2F9.png&amp;w=48&amp;q=75 1x,
        /_next/image?url=%2Fproductos%2F9.png&amp;w=96&amp;q=75 2x"

48 y 96 están en imageSizes, así que son anchos válidos. Si usaras tamano={40}, Next redondearía al ancho permitido superior (48 para 1x, 96 para 2x).

¿No contradice esto la advertencia de la sección 3.1? No: allí la imagen se estiraba con CSS a un tamaño variable. Aquí el tamaño en pantalla es fijo y conocido, así que coincide con lo que pides a Next. El style asegura que el CSS no lo cambie. Fíjate en que el original es de 800 px: lo que importa a Next es la proporción (cuadrada) y el ancho que vas a mostrar.

Ejercicio 2: El banner que no aparece

Un compañero ha escrito este banner para la cabecera del catálogo y no se ve nada:

<div className="w-full">
  <Image src="/productos/10.png" alt="Banner" fill className="object-cover" />
</div>

Arréglalo para que el banner ocupe todo el ancho con proporción 3:1 y descargue una imagen razonable en móvil (pantalla completa) y en escritorio (como mucho 768 px de ancho).

Ver solución

Faltan tres cosas: position: relative en el padre, una altura (aquí, una proporción) y sizes.

<div className="relative aspect-[3/1] w-full overflow-hidden rounded-lg">
  <Image
    src="/productos/10.png"
    alt="Banner"
    fill
    sizes="(max-width: 768px) 100vw, 768px"
    className="object-cover"
  />
</div>
  • relative: la imagen, con position: absolute, se coloca respecto a este div.
  • aspect-[3/1] con w-full: el div tiene altura, calculada a partir del ancho.
  • overflow-hidden y rounded-lg: para que las esquinas redondeadas recorten la imagen.
  • sizes: “hasta 768 px de pantalla ocupo el 100 % del ancho; a partir de ahí, 768 px”. El srcSet generado empieza en 640:
w=640 w=750 w=828 w=1080 w=1200 w=1920 w=2048 w=3840

En un escritorio con densidad 1, el navegador elegirá la de 828w (la primera mayor que 768); en uno de densidad 2, la de 1920w.

Ejercicio 3: Placeholder de color

Las fotos del catálogo están en public/, así que no tienen placeholder="blur" automático. Haz que, mientras cargan, se vea un cuadrado del azul corporativo #3f51b5.

Ver solución

Hace falta un blurDataURL: un PNG minúsculo en base64. Puedes generarlo con sharp, que viene con Next, desde la raíz del proyecto:

node -e "require('sharp')({create:{width:1,height:1,channels:3,background:'#3f51b5'}}).png().toBuffer().then(b=>console.log('data:image/png;base64,'+b.toString('base64')))"

Y lo usas así:

// PNG de 1 x 1 píxel de color #3f51b5
const azul =
  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWOwD9wKAAIYAUa2HpeSAAAAAElFTkSuQmCC'

<Image
  src="/productos/11.png"
  alt="Producto con placeholder"
  width={800}
  height={800}
  placeholder="blur"
  blurDataURL={azul}
  className="w-48"
/>

En el HTML, la <img> lleva como fondo un SVG con ese píxel difuminado (background-image:url("data:image/svg+xml;...), que se ve como un cuadrado azul hasta que llega la foto. Un píxel ocupa unos 100 caracteres: puedes ponerlo en todas las tarjetas sin engordar el HTML.

Si cada producto tuviera su color dominante en la base de datos, generarías el PNG de 1 × 1 de ese color en el servidor y lo pasarías como prop.

Ejercicio 4: Calidades distintas

El departamento de marketing quiere que la portada se vea con calidad 90 y que las miniaturas del catálogo usen 50 para ahorrar datos. Configúralo en Next.js 16 y comprueba que funciona.

Ver solución

En Next.js 16 solo se permite 75 por defecto. Hay que declarar las calidades:

// next.config.ts
images: {
  qualities: [50, 75, 90],
  // ...el resto de la configuración
}

Y usarlas en los componentes:

<Image src={portada} alt="..." fill preload sizes="100vw" quality={90} />
<Image src={`/productos/${p.productoId}.png`} alt={p.nombre} width={800} height={800} quality={50} sizes="..." />

Comprobación: en el srcSet deben aparecer &q=90 y &q=50, y la petición directa debe dar 200. En el laboratorio, con esa configuración:

/_next/image?url=%2Fproductos%2F4.png&w=828&q=90   200 OK
/_next/image?url=%2Fproductos%2F4.png&w=828&q=80   400 "q" parameter (quality) of 80 is not allowed

Sin la configuración, las dos props se habrían convertido en 75 en silencio (en desarrollo, con un aviso en la terminal).

Ejercicio 5: Que la ficha no pierda og:site_name

Comprueba en el <head> de /catalogo/3 que faltan og:site_name, og:locale y og:type si la página define su propio openGraph, y arréglalo sin repetir los valores en cada página.

Ver solución

Es el caso de la sección 13.2. Se extraen los campos comunes:

// lib/metadatos.ts
import type { Metadata } from 'next'

export const openGraphBase = {
  type: 'website',
  locale: 'es_ES',
  siteName: 'Tienda EMPRESA',
} satisfies Metadata['openGraph']

Se usan en el layout (openGraph: openGraphBase) y se propagan en cada página que defina openGraph:

openGraph: {
  ...openGraphBase,
  title: producto.nombre,
  description: producto.descripcion,
  url: `/catalogo/${id}`,
},

Resultado en /catalogo/3, comprobado:

<meta property="og:title" content="Portátil Básico 15"/>
<meta property="og:description" content="Portátil de 15 pulgadas para ofimática"/>
<meta property="og:url" content="http://localhost:3001/catalogo/3"/>
<meta property="og:site_name" content="Tienda EMPRESA"/>
<meta property="og:locale" content="es_ES"/>
<meta property="og:image" content="http://localhost:3001/catalogo/3/opengraph-image?01d0b02243aea0b3"/>
<meta property="og:type" content="website"/>

Para comprobarlo rápido desde la terminal:

curl -s http://localhost:3001/catalogo/3 | grep -oE '<meta property="og:[a-z_:]+" content="[^"]*"'

Ejercicio 6: La tarjeta social del equipo

Crea app/equipo/opengraph-image.tsx que muestre el título “Nuestro equipo”, un círculo de color con las iniciales de cada empleado y el texto “N personas a tu servicio”, con la fuente Poppins.

Ver solución
// app/equipo/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { obtenerEquipo } from '@/lib/tienda'

export const alt = 'El equipo de la Tienda EMPRESA'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

const poppins = await readFile(join(process.cwd(), 'assets/Poppins-Bold.ttf'))
const colores = ['#3f51b5', '#00897b', '#f4511e', '#8e24aa', '#6d4c41', '#039be5', '#c0ca33']

export default async function Imagen() {
  const equipo = await obtenerEquipo()

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          background: '#ffffff',
          fontFamily: 'Poppins',
        }}
      >
        <div style={{ fontSize: 72, color: '#1a237e' }}>Nuestro equipo</div>
        <div style={{ display: 'flex', marginTop: 48 }}>
          {equipo.map((e) => (
            <div
              key={e.empId}
              style={{
                width: 110,
                height: 110,
                borderRadius: 55,
                marginLeft: -16,
                border: '6px solid white',
                background: colores[e.empId % colores.length],
                color: 'white',
                fontSize: 40,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
              }}
            >
              {e.nombre[0] + e.apellidos[0]}
            </div>
          ))}
        </div>
        <div style={{ fontSize: 32, marginTop: 40, color: '#37474f' }}>
          {`${equipo.length} personas a tu servicio`}
        </div>
      </div>
    ),
    { ...size, fonts: [{ name: 'Poppins', data: poppins, style: 'normal', weight: 700 }] },
  )
}

Resultado:

Imagen de Open Graph del equipo con el título, ocho círculos de colores con iniciales y el texto 8 personas a tu servicio

Figura 9. La tarjeta social de /equipo, generada con los datos de la API.

Observaciones:

  • La primera versión falló al compilar con el error de la sección 17.9. Las iniciales ({e.nombre[0]}{e.apellidos[0]}) y el texto final eran dos hijos cada uno. Se ha resuelto concatenando en una sola expresión.
  • Como no depende de params y los datos tienen 'use cache', la imagen es estática: next build la muestra como ○ /equipo/opengraph-image 15m 1y.
  • No hace falta tocar app/equipo/page.tsx: el <head> de /equipo ya incluye og:image apuntando a /equipo/opengraph-image?....

Ejercicio 7: Un sitemap más fino

Cambia el sitemap para que incluya la página del equipo y excluya, además de los descatalogados, los productos sin existencias.

Ver solución
// app/sitemap.ts (cambios)
const fijas: MetadataRoute.Sitemap = [
  { url: SITIO, changeFrequency: 'weekly', priority: 1 },
  { url: `${SITIO}/catalogo`, changeFrequency: 'daily', priority: 0.8 },
  { url: `${SITIO}/equipo`, changeFrequency: 'monthly', priority: 0.3 },
]

const fichas: MetadataRoute.Sitemap = productos
  .filter((p) => !p.descatalogado && p.stock > 0)
  .map((p) => ({
    url: `${SITIO}/catalogo/${p.productoId}`,
    lastModified: p.fechaAlta,
    changeFrequency: 'monthly',
    priority: 0.5,
    images: [`${SITIO}/productos/${p.productoId}.png`],
  }))

Comprobación con los datos de la API EMPRESA: hay 19 productos no descatalogados, de los que 18 tienen existencias. El sitemap pasa a tener 21 URL (3 fijas más 18 fichas):

curl -s http://localhost:3001/sitemap.xml | grep -c "<loc>"
21

¿Es buena idea quitar del sitemap un producto agotado? Depende: si va a reponerse pronto, conviene que siga indexado para no perder su posición en Google. Una alternativa es mantenerlo y mostrar “Agotado” en la ficha.

Ejercicio 8: Migrar un componente de Next.js 15 a 16

Este componente funciona en Next.js 15. Indica qué cambia al pasar a Next.js 16 y reescríbelo para que se comporte igual.

import Image from 'next/image'

export function Destacado() {
  return (
    <Image
      src="/ofertas/banner.png?v=2"
      alt="Oferta de la semana"
      width={800}
      height={800}
      quality={85}
      priority
    />
  )
}
Ver solución

Tres cambios afectan a este componente:

  1. priority sigue funcionando, pero está obsoleta: cámbiala por preload (sección 6.3).
  2. ?v=2 en una imagen local: en la 16 el componente lanza el error de localPatterns (sección 8.4). Hay que permitirlo en la configuración.
  3. quality={85}: en la 16 se convertiría en 75 (sección 8.1). Hay que añadir 85 a qualities.
import Image from 'next/image'

export function Destacado() {
  return (
    <Image
      src="/ofertas/banner.png?v=2"
      alt="Oferta de la semana"
      width={800}
      height={800}
      quality={85}
      preload
    />
  )
}
// next.config.ts
images: {
  qualities: [75, 85],
  localPatterns: [
    { pathname: '/ofertas/**', search: '?v=2' },
    { pathname: '/**', search: '' }, // el resto de imágenes locales, sin consulta
  ],
}

La segunda entrada de localPatterns es importante: al definir la lista, todo lo que no esté en ella queda prohibido, y sin esa línea dejarían de funcionar las demás imágenes de public/.

Comprobado en Next.js 16 con esa entrada de localPatterns: /ofertas/banner.png?v=2 responde 200 y ?v=3, 400. Y en Next.js 15, con priority, el <link rel="preload"> sale idéntico al de preload en la 16.

Ejercicio 9 (propuesto): Dirección de arte

En la portada, usa una imagen horizontal (1600 × 900) en escritorio y una vertical (800 × 1200) en móviles, cambiando a partir de 768 px. Usa getImageProps y <picture> (sección 9.3). Comprueba en las herramientas de desarrollo, con la emulación de dispositivos, que en móvil se descarga la vertical. Piensa: ¿puedes usar preload en este caso? (Pista: relee “Cuándo no usarla” en la sección 6.2.)

Ejercicio 10 (propuesto): Un mapa solo en la página de contacto

Crea una página /contacto que cargue un script externo de mapas (puedes simularlo como el del chat, en public/scripts/mapa.js, creando un objeto window.mapa con un método pintar(idElemento)). El script debe cargarse solo en esa página, con la estrategia adecuada, y el mapa debe volver a pintarse cada vez que vuelvas a la página desde otra con <Link>. ¿Te sirve onLoad o necesitas onReady? Compruébalo navegando varias veces entre la portada y /contacto.

Ejercicio 11 (propuesto): Iconos en varios tamaños con generateImageMetadata

Sustituye app/icon.tsx por una versión que genere tres iconos (16, 32 y 48 px) desde el mismo fichero, exportando generateImageMetadata. Recuerda que en Next.js 16 el id que recibe la función es una promesa. Mira el <head> resultante: ¿cuántas etiquetas <link rel="icon"> hay?

Ejercicio 12 (propuesto): Metadatos de la página de error

Cuando se visita /catalogo/999, generateMetadata devuelve { title: 'Producto no encontrado' }. Añade además robots: { index: false } y comprueba el <head>. Después, lee el aviso de la sección 13.1 sobre el código 200 y averigua en el módulo 14 cómo conseguir un 404 real.


Resumen

Concepto API Idea clave
Imagen optimizada <Image src alt width height /> de next/image Genera srcSet, WebP, loading="lazy" y reserva el hueco con la proporción
Importación estática import foto from './foto.jpg' Dimensiones y blur automáticos; nombre con hash, caché inmutable
Optimizador /_next/image?url&w&q Valida, cachea en .next/cache/images, redimensiona con sharp
Tamaño adaptable sizes="(max-width: 640px) 50vw, 25vw" Debe coincidir con el CSS; sin él, srcSet 1x/2x
Llenar el contenedor fill + object-cover Padre con position y altura; pon siempre sizes
Imagen LCP preload 🆕 16.0 (priority en 15) <link rel="preload"> en el <head>; solo en una imagen
Carga loading="eager" \| "lazy", fetchPriority lazy por defecto; eager para lo visible al cargar
Marcador placeholder="blur", blurDataURL Automático con importación estática; PNG de 1 px en otros casos
Imágenes remotas images.remotePatterns Protocolo, host, puerto, ruta y consulta; cuanto más concreto, mejor
IP privadas images.dangerouslyAllowLocalIP 🔄 16.0 Bloqueadas por SSRF; permítelas solo en desarrollo
Calidades images.qualities 🔄 16.0 [75] por defecto; otras se redondean o dan 400
Anchos deviceSizes, imageSizes 🔄 16.0 Sin 16 en la 16; anchos fuera de lista dan 400
Caché de imágenes images.minimumCacheTTL 🔄 16.0 4 h en la 16 (60 s en la 15); no se puede invalidar
Consultas locales images.localPatterns 🔄 16.0 ?v= exige search exacto en la 16
Redirecciones images.maximumRedirects 🔄 16.0 3 por defecto; más da 508
Sin optimizar unoptimized, loader, loaderFile SVG, imágenes con autenticación o un CDN externo
Dirección de arte getImageProps + <picture> Distintas imágenes según el ancho
Scripts <Script strategy> de next/script beforeInteractive, afterInteractive, lazyOnload; id en los de línea
Metadatos fijos export const metadata: Metadata Solo en layouts y páginas de servidor
Título title: { template, default, absolute } La plantilla se aplica a los hijos
URL absolutas metadataBase En el layout raíz, desde una variable de entorno
Metadatos con datos generateMetadata({ params }, parent) params es una promesa; la misma función de datos que la página
Fusión Superficial, de la raíz a la página openGraph de la página borra el del layout: usa un objeto compartido
Viewport export const viewport: Viewport themeColor y colorScheme van aquí, no en metadata
Iconos favicon.ico, icon.tsx, apple-icon.png Fichero o código; Next añade el <link>
Imagen social opengraph-image.tsx + ImageResponse de next/og 1200 × 630, solo flex, fuentes ttf; params es promesa en la 16 🔄
Mapa del sitio sitemap.ts → /sitemap.xml URL absolutas; images para Google Imágenes
Robots robots.ts → /robots.txt Una petición, no una barrera; other 🆕 16.3
Manifiesto manifest.ts → /manifest.webmanifest Nombre, colores e iconos para instalar la web