Optimización de Imágenes

Las imágenes sin optimizar son la causa #1 de sitios web lentos. NextJS incluye un componente Image que optimiza automáticamente tus imágenes.

El problema con <img>

// ❌ Tag HTML tradicional
<img src="/producto.jpg" alt="Producto" />

Problemas:

  • Se carga en tamaño original (5 MB en vez de 50 KB)
  • No es responsive (misma imagen en móvil y desktop)
  • No usa formatos modernos (WebP, AVIF)
  • No tiene lazy loading (carga todas las imágenes al inicio)
  • Causa Layout Shift (la página "salta" cuando carga)

Resultado: Sitio lento, mal puntaje en Google, usuarios frustrados.

La solución: <Image>

import Image from 'next/image'

// ✅ Componente de NextJS
<Image 
  src="/producto.jpg" 
  alt="Producto"
  width={500}
  height={300}
/>

NextJS automáticamente:

  • ✅ Optimiza el tamaño (reduce 5 MB a 50 KB)
  • ✅ Genera múltiples tamaños (responsive)
  • ✅ Convierte a WebP/AVIF
  • ✅ Lazy loading (carga cuando es visible)
  • ✅ Previene Layout Shift
  • ✅ Cachea agresivamente

Uso básico

Imágenes locales

import Image from 'next/image'
import productoImg from '@/public/producto.jpg'

export default function ProductoCard() {
  return (
    <Image 
      src={productoImg}
      alt="Camiseta azul"
      // width y height opcionales con imports
    />
  )
}

Con import: NextJS conoce el tamaño automáticamente.

Imágenes con path

<Image 
  src="/images/producto.jpg"
  alt="Camiseta azul"
  width={500}
  height={300}
  // width y height REQUERIDOS con paths
/>

Con path: Debes especificar width y height.

Imágenes externas

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'api.mitienda.com',
        port: '',
        pathname: '/images/**',
      },
    ],
  },
}
<Image 
  src="https://api.mitienda.com/images/producto.jpg"
  alt="Producto"
  width={500}
  height={300}
/>
⚠️
Seguridad

Debes autorizar dominios externos en next.config.js. Esto previene que alguien abuse de tu servidor de optimización.

Propiedades principales

fill (imágenes responsivas)

Para imágenes que deben llenar su contenedor:

<div style={{ position: 'relative', width: '100%', height: '400px' }}>
  <Image 
    src="/producto.jpg"
    alt="Producto"
    fill
    style={{ objectFit: 'cover' }}
  />
</div>

Cuándo usar:

  • Imágenes de fondo
  • Hero sections
  • Imágenes que cambian de tamaño según el viewport

sizes (responsive images)

Indica a NextJS qué tamaño tendrá la imagen en diferentes viewports:

<Image 
  src="/producto.jpg"
  alt="Producto"
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Qué significa:

  • Móvil (≤768px): 100% del ancho de viewport
  • Tablet (≤1200px): 50% del ancho
  • Desktop (>1200px): 33% del ancho

NextJS genera y sirve el tamaño apropiado para cada caso.

priority

Para imágenes "above the fold" (visibles inmediatamente):

// Hero image
<Image 
  src="/hero.jpg"
  alt="Banner principal"
  width={1920}
  height={1080}
  priority
/>

Sin priority: Lazy loading (carga cuando llegas a ella) Con priority: Precarga inmediatamente, sin lazy loading

Usa priority en:

  • Hero images
  • Logos principales
  • Primera imagen visible
  • LCP (Largest Contentful Paint)

quality

Controla la calidad de compresión (1-100):

<Image 
  src="/producto.jpg"
  alt="Producto"
  width={500}
  height={300}
  quality={75}  // Default: 75
/>

Recomendaciones:

  • 90-100: Fotografía profesional, portfolios
  • 75-85: E-commerce, blogs (default: 75)
  • 60-70: Thumbnails, imágenes pequeñas

placeholder

Muestra algo mientras la imagen carga:

// Blur placeholder
<Image 
  src="/producto.jpg"
  alt="Producto"
  width={500}
  height={300}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Base64 tiny
/>

Con imports locales:

import productoImg from '@/public/producto.jpg'

<Image 
  src={productoImg}
  alt="Producto"
  placeholder="blur"  // Se genera automáticamente
/>

Ejemplos prácticos

Hero section

import Image from 'next/image'
import heroImg from '@/public/hero.jpg'

export default function Hero() {
  return (
    <section className="relative h-screen">
      <Image 
        src={heroImg}
        alt="Bienvenido a nuestra tienda"
        fill
        priority
        quality={90}
        placeholder="blur"
        style={{ objectFit: 'cover' }}
      />
      
      <div className="relative z-10 flex items-center justify-center h-full">
        <h1 className="text-6xl font-bold text-white">
          Bienvenidos
        </h1>
      </div>
    </section>
  )
}

Card de producto

import Image from 'next/image'

export default function ProductoCard({ producto }) {
  return (
    <article className="border rounded-lg overflow-hidden">
      <div className="relative aspect-square">
        <Image 
          src={producto.imagen}
          alt={producto.nombre}
          fill
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
          style={{ objectFit: 'cover' }}
          className="hover:scale-105 transition-transform"
        />
        
        {producto.descuento && (
          <span className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded">
            -{producto.descuento}%
          </span>
        )}
      </div>
      
      <div className="p-4">
        <h3 className="font-bold text-lg">{producto.nombre}</h3>
        <p className="text-2xl text-blue-600">${producto.precio}</p>
      </div>
    </article>
  )
}

Grid de productos

export default async function ProductosPage() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Productos</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {productos.map((producto) => (
          <ProductoCard key={producto.id} producto={producto} />
        ))}
      </div>
    </div>
  )
}

Galería con lightbox

'use client'

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

export default function Galeria({ imagenes }) {
  const [selected, setSelected] = useState(null)
  
  return (
    <>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        {imagenes.map((img, i) => (
          <div 
            key={i}
            className="relative aspect-square cursor-pointer"
            onClick={() => setSelected(img)}
          >
            <Image 
              src={img}
              alt={`Imagen ${i + 1}`}
              fill
              sizes="(max-width: 768px) 50vw, 25vw"
              style={{ objectFit: 'cover' }}
              className="rounded hover:opacity-80 transition"
            />
          </div>
        ))}
      </div>
      
      {/* Lightbox */}
      {selected && (
        <div 
          className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
          onClick={() => setSelected(null)}
        >
          <div className="relative w-full h-full max-w-4xl max-h-4xl">
            <Image 
              src={selected}
              alt="Imagen ampliada"
              fill
              quality={95}
              style={{ objectFit: 'contain' }}
            />
          </div>
        </div>
      )}
    </>
  )
}

Avatar de usuario

import Image from 'next/image'

type AvatarProps = {
  src: string
  alt: string
  size?: 'sm' | 'md' | 'lg'
}

export default function Avatar({ src, alt, size = 'md' }: AvatarProps) {
  const sizes = {
    sm: 32,
    md: 48,
    lg: 64,
  }
  
  const dimension = sizes[size]
  
  return (
    <div className="relative rounded-full overflow-hidden" style={{ width: dimension, height: dimension }}>
      <Image 
        src={src}
        alt={alt}
        fill
        sizes={`${dimension}px`}
        style={{ objectFit: 'cover' }}
      />
    </div>
  )
}

Formatos de imagen

NextJS automáticamente sirve WebP o AVIF si el navegador lo soporta:

JPEG original: 500 KB → WebP: 150 KB (70% más pequeño) → AVIF: 100 KB (80% más pequeño)

No necesitas hacer nada, NextJS lo maneja automáticamente.

Forzar formato

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
  },
}

Lazy loading

Por defecto, todas las imágenes tienen lazy loading (excepto las con priority).

// ✅ Lazy loading automático
<Image src="/imagen.jpg" alt="Producto" width={500} height={300} />

// ❌ Sin lazy loading
<Image src="/imagen.jpg" alt="Hero" width={500} height={300} priority />

Qué es lazy loading:

  • Las imágenes solo se cargan cuando están cerca de ser visibles
  • Ahorra ancho de banda
  • Página inicial más rápida

Prevenir Layout Shift

Layout Shift es cuando la página "salta" al cargar imágenes.

Con width/height

// ✅ Bien: NextJS reserva espacio
<Image 
  src="/producto.jpg"
  alt="Producto"
  width={500}
  height={300}
/>

Con aspect ratio

// ✅ Bien con fill
<div className="relative aspect-video">
  <Image 
    src="/producto.jpg"
    alt="Producto"
    fill
    style={{ objectFit: 'cover' }}
  />
</div>

Aspect ratios comunes

// Square (1:1)
<div className="relative aspect-square">

// Video (16:9)
<div className="relative aspect-video">

// Portrait (3:4)
<div className="relative aspect-[3/4]">

// Custom
<div className="relative aspect-[4/3]">

Loaders personalizados

Para usar CDNs de imágenes:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/imageLoader.js',
  },
}
// lib/imageLoader.js
export default function cloudinaryLoader({ src, width, quality }) {
  const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`]
  return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`
}

Configuración avanzada

Tamaños de dispositivos

// next.config.js
module.exports = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
}

NextJS genera imágenes en estos tamaños.

Dominios permitidos (legacy)

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.example.com',
      },
      {
        protocol: 'https',
        hostname: 'cdn.mitienda.com',
        pathname: '/uploads/**',
      },
    ],
  },
}

Desactivar optimización

// next.config.js
module.exports = {
  images: {
    unoptimized: true,  // No optimizar (no recomendado)
  },
}

Solo usa esto si despliegas en un hosting que no soporta optimización de imágenes.

Performance

Medir impacto

Usa Lighthouse para medir:

# Chrome DevTools → Lighthouse
# Métricas importantes:
# - LCP (Largest Contentful Paint)
# - CLS (Cumulative Layout Shift)
# - Speed Index

Sin Image: LCP = 4s, CLS = 0.3 Con Image: LCP = 1.2s, CLS = 0

Tips de performance

  1. Usa priority solo en la primera imagen visible
// ✅ Bien
<Image src="/hero.jpg" priority />  // Solo el hero

// ❌ Mal
<Image src="/producto1.jpg" priority />
<Image src="/producto2.jpg" priority />
<Image src="/producto3.jpg" priority />  // Todas priority = ninguna priority
  1. Define sizes correctamente
// ❌ Mal: NextJS carga imagen de 1920px en móvil
<Image src="/img.jpg" width={1920} height={1080} />

// ✅ Bien: Carga tamaño apropiado por device
<Image 
  src="/img.jpg" 
  width={1920} 
  height={1080}
  sizes="(max-width: 768px) 100vw, 50vw"
/>
  1. Comprime imágenes antes de subirlas

Herramientas:

NextJS optimiza, pero partir de imágenes más pequeñas es mejor.

Imágenes desde CMS

Con Contentful

import Image from 'next/image'

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      
      <Image 
        src={`https:${post.coverImage.url}`}
        alt={post.coverImage.description}
        width={post.coverImage.width}
        height={post.coverImage.height}
        priority
      />
      
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

Con Cloudinary

import Image from 'next/image'

const cloudinaryLoader = ({ src, width, quality }) => {
  return `https://res.cloudinary.com/tu-cloud/image/upload/w_${width},q_${quality || 'auto'}/${src}`
}

export default function Producto({ producto }) {
  return (
    <Image 
      loader={cloudinaryLoader}
      src={producto.imagenId}
      alt={producto.nombre}
      width={500}
      height={300}
    />
  )
}

Patrones comunes

Imagen con skeleton

'use client'

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

export default function ProductoImagen({ src, alt }) {
  const [loading, setLoading] = useState(true)
  
  return (
    <div className="relative aspect-square">
      {loading && (
        <div className="absolute inset-0 bg-gray-200 animate-pulse" />
      )}
      
      <Image 
        src={src}
        alt={alt}
        fill
        style={{ objectFit: 'cover' }}
        onLoadingComplete={() => setLoading(false)}
      />
    </div>
  )
}

Imagen con fallback

'use client'

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

export default function ProductoImagen({ src, alt }) {
  const [error, setError] = useState(false)
  
  if (error) {
    return (
      <div className="aspect-square bg-gray-200 flex items-center justify-center">
        <span className="text-gray-400">Sin imagen</span>
      </div>
    )
  }
  
  return (
    <Image 
      src={src}
      alt={alt}
      width={500}
      height={500}
      onError={() => setError(true)}
    />
  )
}

Galería con navegación

'use client'

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

export default function ProductoGaleria({ imagenes }) {
  const [actual, setActual] = useState(0)
  
  return (
    <div>
      {/* Imagen principal */}
      <div className="relative aspect-square mb-4">
        <Image 
          src={imagenes[actual]}
          alt={`Imagen ${actual + 1}`}
          fill
          priority
          quality={90}
          style={{ objectFit: 'cover' }}
        />
      </div>
      
      {/* Thumbnails */}
      <div className="grid grid-cols-4 gap-2">
        {imagenes.map((img, i) => (
          <button
            key={i}
            onClick={() => setActual(i)}
            className={`relative aspect-square ${
              i === actual ? 'ring-2 ring-blue-500' : ''
            }`}
          >
            <Image 
              src={img}
              alt={`Thumbnail ${i + 1}`}
              fill
              sizes="25vw"
              style={{ objectFit: 'cover' }}
            />
          </button>
        ))}
      </div>
    </div>
  )
}

Mejores prácticas

1. Siempre usa Image en vez de img

// ❌ Nunca
<img src="/producto.jpg" alt="Producto" />

// ✅ Siempre
<Image src="/producto.jpg" alt="Producto" width={500} height={300} />

2. Define alt descriptivos

// ❌ Mal
<Image src="/producto.jpg" alt="imagen" />

// ✅ Bien
<Image src="/producto.jpg" alt="Camiseta azul de algodón talla M" />

3. Usa priority solo cuando necesario

// ✅ Hero visible inmediatamente
<Image src="/hero.jpg" priority />

// ❌ Imagen que está abajo en la página
<Image src="/footer-logo.jpg" priority />  // No necesario

4. Organiza tus imágenes

public/
├── images/
│   ├── productos/
│   │   ├── camisa-1.jpg
│   │   └── camisa-2.jpg
│   ├── hero/
│   │   └── home-hero.jpg
│   └── logos/
│       └── brand-logo.png

5. Comprime antes de subir

  • JPEG: Fotos, imágenes complejas
  • PNG: Logos, iconos, transparencias
  • SVG: Iconos, ilustraciones simples
  • WebP: Alternativa moderna (mejor que JPEG/PNG)

Errores comunes

Error: No width/height

// ❌ Error: Falta width y height
<Image src="/producto.jpg" alt="Producto" />

// ✅ Solución 1: Especificar dimensiones
<Image src="/producto.jpg" alt="Producto" width={500} height={300} />

// ✅ Solución 2: Usar fill
<div className="relative aspect-square">
  <Image src="/producto.jpg" alt="Producto" fill />
</div>

Error: Dominio no autorizado

Error: Invalid src prop on `next/image`, hostname "example.com" is not configured
// Solución: next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'example.com',
      },
    ],
  },
}

Error: Layout shift

// ❌ Causa layout shift
<Image src="/img.jpg" alt="Imagen" />

// ✅ Previene layout shift
<Image src="/img.jpg" alt="Imagen" width={500} height={300} />

Resumen

Componente Image de NextJS:

  • Optimización automática de tamaño
  • Conversión a formatos modernos (WebP/AVIF)
  • Lazy loading por defecto
  • Previene Layout Shift
  • Responsive images automático

Propiedades clave:

  • width y height para dimensiones fijas
  • fill para imágenes responsive
  • priority para primera imagen visible
  • sizes para responsive correcto
  • quality para controlar compresión

Regla de oro: Siempre usa Image en vez de img. La optimización es automática y dramáticamente mejora la performance de tu sitio.