1. Introducción contextual
En la última década la velocidad de carga se ha convertido en un factor decisivo para la rentabilidad de cualquier proyecto web. Según el Google PageSpeed Insights 2024, un retraso de 1 s en la carga de la página reduce la conversión en un 7 % en e‑commerce y aumenta la tasa de rebote en un 16 % en sitios de contenido. Dentro de los factores que influyen en la velocidad, las imágenes representan entre el 50 % y el 70 % del peso total de una página.
Next.js, el framework React de Vercel, ha evolucionado de ser una solución de renderizado híbrido a una plataforma completa de optimización de recursos. Desde la versión 13, el componente <Image> y la arquitectura de Edge Functions permiten servir versiones adaptativas de los recursos visuales, reduciendo el Time to First Byte (TTFB) y mejorando los Core Web Vitals (LCP, CLS, FID).
Sin embargo, la simple inclusión del componente <Image> no garantiza la mejor performance. La elección del formato, la dimensión, la caché y la estrategia de carga siguen siendo decisiones de ingeniería que deben alinearse con los requisitos de negocio (SEO, accesibilidad, costos de CDN, etc.). Este artículo explora, paso a paso, cómo diseñar y ejecutar una solución robusta de optimización de imágenes en Next.js, con ejemplos de código listos para producción y análisis de trade‑offs críticos.
2. Fundamentos y Arquitectura
2.1 Conceptos clave
| Concepto | Descripción | Impacto en Next.js |
|---|---|---|
| Responsive Images | Servir diferentes resoluciones según el viewport. | <Image> genera srcset y sizes automáticamente. |
| Formato Adaptativo | Utilizar AVIF/WebP cuando el navegador lo soporta. | Next.js detecta el soporte a nivel de Edge y entrega el mejor formato. |
| Lazy Loading | Cargar la imagen sólo cuando entra en el viewport. | <Image loading="lazy"> habilita IntersectionObserver. |
| Cache‑Control | Definir TTL y políticas de revalidación. | next.config.js permite encabezados personalizados en la capa Edge. |
| CDN Edge | Distribuir contenido cerca del usuario final. | Vercel Edge Network o cualquier CDN compatible con Next.js. |
2.2 Decisiones de diseño
- Centralizar la lógica de carga mediante un loader custom (Factory) que abstraiga la fuente de la imagen (local, S3, Cloudinary).
- Aplicar el patrón Repository para la persistencia de metadatos (dimensiones, hash, versiones) en PostgreSQL vía Supabase.
- Utilizar Singleton para la configuración de la CDN, evitando recreaciones costosas en cada request.
2.3 Diagrama conceptual (descrito en texto)
- Cliente → solicita página HTML.
- Next.js (Edge) → renderiza la página, inserta
<Image>consrcapuntando a ImageLoaderFactory. - ImageLoaderFactory decide la URL final:
- Si la imagen está en Supabase Storage, genera una URL firmada con expiración.
- Si está en Cloudinary, aplica transformaciones (
f_auto,q_auto).
- CDN Edge → entrega la versión optimizada (WebP/AVIF) según
Acceptheader. - Cache → controla la revalidación mediante
Cache-Control: public, max‑age=31536000, immutable.
3. Implementación práctica
A continuación se muestra la evolución de una solución de optimización de imágenes en Next.js, desde una implementación naïve hasta una arquitectura optimizada y extensible.
3.1 Enfoque básico: <img> estático
// pages/product/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchProduct } from '../../lib/api';
type Props = {
product: {
id: string;
name: string;
imageUrl: string; // URL directa a S3
};
};
export default function ProductPage({ product }: Props) {
return (
<section>
<h1>{product.name}</h1>
{/* Imagen sin optimización */}
<img src={product.imageUrl} alt={product.name} width={800} height={600} />
</section>
);
}
// ... getStaticProps / getStaticPaths omitidos por brevedad
Problemas:
- No hay
srcset, por lo que el navegador siempre descarga la misma resolución. - No se aprovecha lazy loading.
- No se sirve formato WebP/AVIF.
3.2 Primer salto: uso del componente <Image> de Next.js
// pages/product/[id].tsx
import Image from 'next/image';
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchProduct } from '../../lib/api';
type Props = {
product: {
id: string;
name: string;
imageUrl: string; // URL directa a S3
};
};
export default function ProductPage({ product }: Props) {
return (
<section>
<h1>{product.name}</h1>
{/* Next.js Image habilita srcset, lazy loading y formatos adaptativos */}
<Image
src={product.imageUrl}
alt={product.name}
width={800}
height={600}
placeholder="blur"
blurDataURL="/placeholder.png"
quality={80}
/>
</section>
);
}
Ventajas:
- Genera
srcsetautomáticamente. - Lazy loading por defecto (
loading="lazy"). - Aplica optimización de calidad (
quality).
Limitaciones:
- El loader por defecto solo funciona con dominios declarados en
next.config.js. - No hay control de transformación (corte, watermark).
3.3 Loader custom con Factory (versión intermedia)
// lib/imageLoader.ts
import { URL } from 'url';
/**
* Interfaz del loader. Cada implementación debe devolver una URL
* optimizada que incluya parámetros de calidad y formato.
*/
export interface ImageLoader {
buildUrl(src: string, width: number, quality: number): string;
}
/**
* Loader para Supabase Storage (usa query params para transformación).
*/
class SupabaseLoader implements ImageLoader {
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
buildUrl(src: string, width: number, quality: number): string {
const url = new URL(`${this.baseUrl}/${src}`);
url.searchParams.set('width', width.toString());
url.searchParams.set('quality', quality.toString());
url.searchParams.set('format', 'auto'); // Cloudflare o Supabase pueden decidir
return url.toString();
}
}
/**
* Loader para Cloudinary (aplica f_auto,q_auto).
*/
class CloudinaryLoader implements ImageLoader {
private readonly cloudName: string;
constructor(cloudName: string) {
this.cloudName = cloudName;
}
buildUrl(src: string, width: number, quality: number): string {
// Cloudinary URL pattern: https://res.cloudinary.com/<cloud>/image/upload/w_<w>,q_<q>/path
return `https://res.cloudinary.com/${this.cloudName}/image/upload/w_${width},q_${quality},f_auto/${src}`;
}
}
/**
* Factory que devuelve la implementación adecuada según la variable de entorno.
*/
export class ImageLoaderFactory {
private static instance: ImageLoader | null = null;
static getLoader(): ImageLoader {
if (this.instance) return this.instance;
const provider = process.env.IMAGE_PROVIDER;
if (provider === 'cloudinary') {
this.instance = new CloudinaryLoader(process.env.CLOUDINARY_CLOUD_NAME!);
} else {
// fallback a Supabase
this.instance = new SupabaseLoader(process.env.SUPABASE_STORAGE_URL!);
}
return this.instance;
}
}
// components/OptimizedImage.tsx
import Image, { ImageProps } from 'next/image';
import { ImageLoaderFactory } from '../lib/imageLoader';
/**
* Wrapper que delega la construcción de la URL al loader factory.
*/
export const OptimizedImage = (props: ImageProps) => {
const { src, width = 800, quality = 75, ...rest } = props;
const loader = ImageLoaderFactory.getLoader();
// Next.js permite pasar una función `loader` personalizada.
const customLoader = ({ src, width, quality }: { src: string; width: number; quality?: number }) => {
return loader.buildUrl(src, width, quality ?? 75);
};
return (
<Image
src={src as string}
width={width}
quality={quality}
loader={customLoader}
placeholder="blur"
blurDataURL="/placeholder.png"
{...rest}
/>
);
};
// pages/product/[id].tsx (uso del wrapper)
import { OptimizedImage } from '../../components/OptimizedImage';
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchProduct } from '../../lib/api';
type Props = {
product: {
id: string;
name: string;
imagePath: string; // ruta relativa en el bucket
};
};
export default function ProductPage({ product }: Props) {
return (
<section>
<h1>{product.name}</h1>
<OptimizedImage
src={product.imagePath}
alt={product.name}
width={1200}
height={800}
quality={85}
/>
</section>
);
}
Trade‑offs:
- Ventajas: Centraliza la lógica de generación de URLs, permite cambiar de proveedor sin tocar componentes.
- Desventajas: Añade una capa de abstracción que incrementa la complejidad inicial.
3.4 Optimización a nivel de build: next-optimized-images (deprecated) → next-image-export-optimizer
Aunque el plugin next-optimized-images está en desuso, la comunidad ha adoptado next-image-export-optimizer para generar versiones estáticas de AVIF y WebP durante el build.
# .bashrc (o script de CI)
export NEXT_IMAGE_OPTIMIZER=true
# next.config.mjs
import imageOptimizer from 'next-image-export-optimizer';
import { defineConfig } from 'next';
export default defineConfig({
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'my-supabase.storage',
pathname: '/**',
},
],
},
// Integración del optimizador en la fase de export
experimental: {
images: {
unoptimized: false,
},
},
webpack(config, { isServer }) {
if (!isServer) {
config.module.rules.push({
test: /\.(jpe?g|png|gif|svg)$/i,
use: [
{
loader: imageOptimizer.loader,
options: {
// Genera AVIF y WebP, mantiene JPEG como fallback
formats: ['avif', 'webp'],
quality: 80,
// Tamaños de ruptura comunes
widths: [320, 640, 960, 1280, 1600, 1920],
},
},
],
});
}
return config;
},
});
Beneficios:
- Las imágenes se procesan una sola vez en CI, reduciendo la carga en Edge.
- Los archivos resultantes son servidos como estáticos, lo que permite
Cache-Control: immutable.
Consideraciones:
- El proceso de build se vuelve más costoso (CPU intensivo). Se recomienda ejecutarlo en runners con recursos dedicados.
3.5 Estrategia avanzada: Edge Middleware + Dynamic Image Service (Node.js)
Para casos donde la imagen necesita transformaciones dinámicas (corte, overlay, branding), se puede exponer un API Route que actúe como micro‑servicio de imágenes, ejecutado en Edge.
// src/pages/api/_image.ts
import type { NextRequest } from 'next/server';
import sharp from 'sharp';
import { supabaseClient } from '../../lib/supabase';
// Singleton para el cliente Supabase (evita reconexiones)
let supabase = supabaseClient;
/**
* Middleware que recibe parámetros de query:
* ?src=path/to/img.jpg&width=1200&format=webp&fit=crop
*/
export const config = {
runtime: 'edge',
};
export default async function handler(req: NextRequest) {
const url = new URL(req.url);
const src = url.searchParams.get('src');
const width = Number(url.searchParams.get('width')) || 800;
const format = (url.searchParams.get('format') as 'webp' | 'avif' | 'jpeg') || 'webp';
const fit = (url.searchParams.get('fit') as 'cover' | 'contain') || 'cover';
if (!src) {
return new Response('Missing src', { status: 400 });
}
// 1️⃣ Obtener la imagen original desde Supabase Storage
const { data, error } = await supabase.storage.from('images').download(src);
if (error || !data) {
return new Response('Image not found', { status: 404 });
}
// 2️⃣ Transformar con Sharp (corte, redimensionado, conversión)
const transformed = await sharp(await data.arrayBuffer())
.resize({ width, fit })
.toFormat(format, { quality: 85 })
.toBuffer();
// 3️⃣ Responder con encabezados de caché
const headers = new Headers({
'Content-Type': `image/${format}`,
'Cache-Control': 'public, max-age=31536000, immutable',
});
return new Response(transformed, { status: 200, headers });
}
// components/DynamicOptimizedImage.tsx
import Image from 'next/image';
import { useMemo } from 'react';
/**
* Wrapper que apunta al API Edge para transformaciones dinámicas.
*/
export const DynamicOptimizedImage = ({
src,
alt,
width,
height,
format = 'webp',
fit = 'cover',
}: {
src: string;
alt: string;
width: number;
height: number;
format?: 'webp' | 'avif' | 'jpeg';
fit?: 'cover' | 'contain';
}) => {
const url = useMemo(() => {
const params = new URLSearchParams({
src,
width: width.toString(),
format,
fit,
});
return `/api/_image?${params.toString()}`;
}, [src, width, format, fit]);
return (
<Image
src={url}
alt={alt}
width={width}
height={height}
placeholder="blur"
blurDataURL="/placeholder.png"
/>
);
};
Ventajas de esta arquitectura:
| Aspecto | Enfoque estático (next‑image‑export‑optimizer) | Edge Middleware + Sharp |
|---|---|---|
| Latencia | Muy baja (CDN entrega archivo pre‑generado) | Moderada (procesamiento en |
