Módulo 07: Imágenes, fuentes y metadatos
Objetivos del módulo
- Entender qué problemas tiene una etiqueta
<img>normal y cómo los resuelvenext/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 yfill, y escribir unsizesque no desperdicie bytes - Decidir qué imagen se carga primero con
preload,loadingyfetchPriority, 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/imageen Next.js 16 y cómo reproducirlos (o evitarlos) en Next.js 15 - Cargar scripts de terceros con
next/scriptsin 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
ImageResponsedenext/og - Crear
sitemap.xml,robots.txty 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
metadatao una funcióngenerateMetadataque Next convierte en etiquetas del<head>. - Los ficheros especiales de metadatos:
icon,opengraph-image,sitemap,robotsymanifest, 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 connode server.mjsy queda enhttp://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:
- 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.
- 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.
- 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.
- 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. - Todo a mano. Puedes resolver lo anterior con
srcset,sizes,<picture>,width,heighty un servicio que genere las variantes. Es mucho trabajo, y hay que repetirlo en cada imagen.
📘 Concepto:
srcsetes un atributo de<img>que ofrece al navegador varias versiones de la misma imagen, ysizesle 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/imagelos 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>
)
}
srcempieza por/porque apunta apublic/, igual que con<img>.altes obligatorio. Describe la imagen para quien usa un lector de pantalla y para los buscadores. Si la imagen es puramente decorativa, escribealt=""(vacío, pero presente).widthyheightson 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
widthyheightel 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, consizes, 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&w=828&q=75 1x,
/_next/image?url=%2Fproductos%2F4.png&w=1920&q=75 2x"
src="/_next/image?url=%2Fproductos%2F4.png&w=1920&q=75"/>
Fíjate en cuatro cosas:
loading="lazy"ydecoding="async"vienen puestos. Las imágenes se cargan cuando se acercan a la pantalla.widthyheightpasan 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.- 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. - El
srcSettiene dos versiones,1xy2x, 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
widthniheight: 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:
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é.
- Validación. Next comprueba los tres parámetros de la URL:
url(qué imagen),w(qué ancho) yq(qué calidad). Si algo no está permitido por la configuración, responde 400 con un mensaje de texto. - Caché. Busca en
.next/cache/imagessi ya optimizó esa combinación. Si está y no ha caducado, la devuelve directamente. - Original. Si no está, lee la imagen original: de
public/, del resultado de una importación estática o de un servidor remoto. - 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. - Respuesta. La envía con una cabecera
Cache-Controly 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ó enAcceptque entiende WebP, y Next lo ha convertido.X-Nextjs-Cache: MISSy luegoHIT: 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ónminimumCacheTTL, 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 pedirw=1,w=2,w=3… hasta llenar tu disco y agotar tu CPU. Casi todos los cambios denext/imageen 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
Acceptdel 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 elsrcSetno sea interminable. En la ficha de producto, consizes="(max-width: 768px) 100vw, 50vw", elsrcSetempezaba en 384w.
⚠️ Importante: Si tu
sizesno coincide con el CSS, el navegador elige mal. Si declaras25vwpero la imagen en realidad ocupa toda la pantalla en móvil, el usuario verá una imagen borrosa. Si declaras100vwy ocupa la cuarta parte, descargará el cuádruple de lo necesario. Cuando cambies la rejilla, cambiasizes.
5.2. Cómo comprobar qué imagen ha elegido el navegador
No te fíes: compruébalo. En las herramientas de desarrollo:
- Pestaña Elementos, selecciona la
<img>y busca en la pestaña Propiedades el valor decurrentSrc. Ahí verás la URL elegida, con suw=. - Pestaña Red, filtro Img: verás qué
/_next/image?...&w=...se ha descargado realmente y cuánto pesa. - 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:
- El padre debe tener posición:
relative,absoluteofixed. Si no, la imagen se posiciona respecto a otro antepasado y acaba ocupando media página. - El padre debe tener altura. Sin
h-[420px](o una proporción conaspect-video, por ejemplo), el contenedor mide 0 px de alto y no se ve nada. - Decide cómo encaja con
object-fit:object-coverrecorta para llenar el hueco sin deformar;object-containmuestra 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.

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: usapriority, 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&w=640&q=75 640w, ...
/_next/image?url=...portada...&w=3840&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
loadingofetchPriority: 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,
priorityes la forma de precargar una imagen. En la 16 sigue funcionando, pero está marcada como obsoleta (@deprecated Use preload prop instead) en favor depreload. Si usas Next.js 15: sigue usandopriority; al migrar, cámbiala porpreload.
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&w=828&q=75 1x,
/_next/image?url=%2Fproductos%2F5.png&w=1920&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:
blurDataURLdebe 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 queremotePatternscierra. Y omitirpathnameen 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 siempreremotePatterns.
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é enlocalhosto en tu red privada. En la 16 rechaza las IP privadas y responde 400, salvo que activesimages.dangerouslyAllowLocalIP. Si usas Next.js 15: no tienes esa protección ni la opción para desactivarla; limitaremotePatternsal 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:

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:
- En producción, usa un servidor público (un CDN, un bucket): el problema desaparece solo, porque no es una IP privada.
-
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. Ennext buildynext startsigue bloqueada. - 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:

Figura 4. La misma página con dangerouslyAllowLocalIP activado: las fotos llegan optimizadas.
⚠️ Importante: El prefijo
dangerouslyno 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, dejaremotePatternsrestringido a ese único host y ruta.
💡 Consejo: Fíjate en que la página no se rompe cuando una imagen falla:
onErrorla 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
imagesson 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 ennext.config.tsla 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 propqualityse redondea al valor permitido más cercano y una petición directa con otraqda 400. Si usas Next.js 15: funciona sin configurar nada; si quieres el comportamiento de la 16, añadequalities: [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,
imageSizespor 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, declaraimageSizes: [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,
minimumCacheTTLvale 60 segundos; en la 16, 14 400 (4 horas). Si usas Next.js 15: ponminimumCacheTTL: 14400si 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 configurarlocalPatterns(sección 8.4). - Bajar
minimumCacheTTLy borrar.next/cache/imagesal 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
minimumCacheTTLde 60 a 14 400 sin borrar la caché, una imagen ya optimizada seguía saliendo conmax-age=60, mientras que otra nueva salía conmax-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 conimages.localPatternsy su camposearch. Si usas Next.js 15: funciona sin configurar;localPatternsexiste 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 pornext/image.images.domainsestá obsoleta desde la 14 (sección 7.1). Cámbiala porremotePatterns.
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
loaderes 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
loaderen cada una: declara un fichero de cargador global ennext.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
srcacaba en.svg, activaunoptimizedsolo. - 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: truepara que el optimizador procese SVG. Un SVG puede contener<script>, así que, si la activas, configura tambiéncontentDispositionType: 'attachment'ycontentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", como pide la documentación. En la práctica, casi nunca hace falta: usaunoptimizedcon 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
placeholdercongetImageProps. El componenteImagequita el fondo borroso cuando la imagen termina de cargar, pero congetImagePropsno 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. Con0, 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,otfywoff(nowoff2). Mejorttfuotf, 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:
onLoadse 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), usaonReady, 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:
metadataygenerateMetadatasolo se admiten en Server Components, nunca en un fichero con'use client'. Si tu página necesita ser de cliente, dejapage.tsxcomo 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 (
charsetyviewport) se añaden siempre, aunque no definas nada. og:title,og:description,twitter:titleytwitter:descriptionno estaban enopenGraphni entwitter: Next los ha copiado deltitley ladescriptionde 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 enmetadata.- Las URL son absolutas (
http://localhost:3001/...) gracias ametadataBase.
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' }enapp/layout.tsx, la portadaapp/page.tsxsin título propio recibe eldefault, pero si le ponestitle: 'Inicio', saleInicio | Tienda. Y en unpage.tsx,templateno 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
metadataBasesiempre en el layout raíz y nunca dejes en el código una URL de desarrollo comolocalhost: las redes sociales reciben la URL de la imagen tal cual y, si apunta alocalhost, 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:
paramses una promesa y hay que esperarla conawait, 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, engenerateMetadatay 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,
generateMetadatadevuelve un título genérico y la página llama anotFound().
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/999no 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,noindextiene 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.
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:
satisfiescomprueba que el objeto encaja con el tipoMetadata['openGraph']sin “ensanchar” su tipo. Si escribierasconst openGraphBase: Metadata['openGraph'] = {...}, TypeScript diría que puede serundefinedy 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
fetchcon la misma URL y opciones, y la funcióncachede React permite hacer lo mismo con cualquier función (por ejemplo, una consulta a la base de datos). - En este proyecto, además,
obtenerProductousa'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),generateMetadatasigue las mismas reglas que cualquier componente: si pide datos sin caché y el resto de la página es estático,next buildfalla 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>.
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 },
)
}
sizeycontentTypeson exports de configuración: Next los usa para escribirsizes="32x32"ytype="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,/iconaparece 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
flexboxy posicionamiento absoluto.display: gridno funciona. - Solo estilos en línea (
style={{...}}). No hayclassName, ni Tailwind, ni hojas de estilo. - Todo
<div>con más de un hijo debe tenerdisplay: flex(ocontents, onone). 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,otfowoff. - 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.
altse convierte enog:image:alt. No es opcional en la práctica: los lectores de pantalla lo leen al encontrar la tarjeta.- Next añade
og:imageytwitter:imagecon la misma URL. Si quieres una distinta para X, creatwitter-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 unopengraph-image.alt.txtcon 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,iconyapple-iconrecibeparamscomo un objeto normal. En la 16 recibe una promesa (yid, si usasgenerateImageMetadata, también). Si usas Next.js 15: escribe la firma como({ params }: { params: { id: string } })y usaparams.idsinawait.
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:

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
awaiten el nivel superior del módulo (fuera de la función).process.cwd()es la raíz del proyecto, así que el fichero está enassets/Poppins-Bold.ttf. fontFamily: 'Poppins'coincide con elnamede la fuente enfonts.weight: 700debe corresponder al fichero:Poppins-Bold.ttfes el grosor 700.euros.formates unIntl.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 cadaid. En Next.js 16 eseidllega 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 conSITIO. imagesañade el espacio de nombresimage:de Google, que ayuda a que tus fotos aparezcan en Google Imágenes.changeFrequencyypriorityson orientativos: cada buscador decide si los tiene en cuenta. Lo que más importa es que estén todas las URL y quelastModifiedsea 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
generateSitemapspara partirlo en varios ficheros (/catalogo/sitemap/0.xml,/catalogo/sitemap/1.xml…). Según la guía de migración, en Next.js 16 elidque recibe tu funciónsitemapen ese caso es una promesa, igual que losparamsde 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.txtes 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
othery TypeScript lo rechaza ('other' does not exist in type...). Si usas Next.js 15: escribe el fichero estáticoapp/robots.txta mano con las directivas que necesites, en lugar derobots.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ónexperimental.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 ennext buildcon 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 devuelvepagina.contenido. 'use cache'guarda el resultado de la función. Sin ella, Next.js 16 con Cache Components no puede prerenderizar estas páginas ynext buildfalla 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,turbopacknidangerouslyAllowLocalIP(que no existen allí). Basta conimages: { remotePatterns: [...] }.
16.4. El catálogo
// 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>
)
}

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):
- Portada. Ver el código fuente (
Ctrl+U): el<head>empieza con un<link rel="preload" as="image">de la portada. - Catálogo. Pestaña Red, filtro Img, recarga: las imágenes son
/_next/image?...&w=..., de tipowebp, y las de abajo no se piden hasta que haces scroll. -
Ficha. http://localhost:3001/catalogo/3: el título de la pestaña es “Portátil Básico 15Tienda EMPRESA”. - Tarjeta social.
http://localhost:3001/catalogo/3/opengraph-imagemuestra la imagen de la figura 7. - Equipo. En desarrollo se ven las fotos. En producción, sin
dangerouslyAllowLocalIP, salen las iniciales: es el bloqueo de IP locales. - Ficheros especiales.
/robots.txt,/sitemap.xml,/manifest.webmanifeste/iconresponden. - Cabeceras.
curl -I "http://localhost:3001/_next/image?url=%2Fproductos%2F1.png&w=640&q=75"daCache-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:imagey 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:
- El padre no tiene altura: un
divsinh-...niaspect-...mide 0 px de alto. - El padre no tiene
position: la imagen se coloca respecto a otro antepasado y aparece en otro sitio, o tapando media página. - Un padre
flexogridencoge 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/heightel tamaño en pantalla y no el del fichero, y el CSS la estira. sizesdice que ocupa menos de lo que ocupa realmente.- En Next.js 16, tu
quality={90}se está convirtiendo en 75 porque no está enqualities(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 usametadata. - Exportaste
metadatadesde un componente que no eslayoutnipage(por ejemplo, un componente deapp/catalogo/Tarjeta.tsx): Next solo lo lee de layouts y páginas. - Exportaste a la vez
metadataygenerateMetadataen 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&w=48&q=75 1x,
/_next/image?url=%2Fproductos%2F9.png&w=96&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, conposition: absolute, se coloca respecto a estediv.aspect-[3/1]conw-full: eldivtiene altura, calculada a partir del ancho.overflow-hiddenyrounded-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”. ElsrcSetgenerado 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:

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
paramsy los datos tienen'use cache', la imagen es estática:next buildla muestra como○ /equipo/opengraph-image 15m 1y. - No hace falta tocar
app/equipo/page.tsx: el<head>de/equipoya incluyeog:imageapuntando 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:
prioritysigue funcionando, pero está obsoleta: cámbiala porpreload(sección 6.3).?v=2en una imagen local: en la 16 el componente lanza el error delocalPatterns(sección 8.4). Hay que permitirlo en la configuración.quality={85}: en la 16 se convertiría en 75 (sección 8.1). Hay que añadir 85 aqualities.
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 |