1. Contextual Introduction
In the last decade, load speed has become a decisive factor for the profitability of any web project. According to Google PageSpeed Insights 2024, a 1 s delay in page load reduces e‑commerce conversion by 7 % and increases bounce rate by 16 % on content sites. Among the factors that influence speed, images account for between 50 % and 70 % of a page’s total weight.
Next.js, Vercel’s React framework, has evolved from a hybrid rendering solution to a full‑blown resource‑optimization platform. Since version 13, the <Image> component and the Edge Functions architecture allow serving adaptive versions of visual assets, reducing Time to First Byte (TTFB) and improving the Core Web Vitals (LCP, CLS, FID).
However, merely including the <Image> component does not guarantee optimal performance. The choice of format, dimension, caching, and loading strategy remain engineering decisions that must align with business requirements (SEO, accessibility, CDN costs, etc.). This article walks through, step by step, how to design and execute a robust image‑optimization solution in Next.js, with production‑ready code examples and analysis of critical trade‑offs.
2. Fundamentals and Architecture
2.1 Key Concepts
| Concept | Description | Impact on Next.js |
|---|---|---|
| Responsive Images | Serve different resolutions based on the viewport. | <Image> automatically generates srcset and sizes. |
| Adaptive Format | Use AVIF/WebP when the browser supports it. | Next.js detects support at the Edge level and delivers the best format. |
| Lazy Loading | Load the image only when it enters the viewport. | <Image loading="lazy"> enables IntersectionObserver. |
| Cache‑Control | Define TTL and revalidation policies. | next.config.js allows custom headers at the Edge layer. |
| Edge CDN | Distribute content close to the end user. | Vercel Edge Network or any CDN compatible with Next.js. |
2.2 Design Decisions
- Centralize loading logic through a custom loader (Factory) that abstracts the image source (local, S3, Cloudinary).
- Apply the Repository pattern for persisting metadata (dimensions, hash, versions) in PostgreSQL via Supabase.
- Use a Singleton for CDN configuration, avoiding costly recreations on every request.
2.3 Conceptual Diagram (described in text)
- Client → requests HTML page.
- Next.js (Edge) → renders the page, inserts
<Image>withsrcpointing to ImageLoaderFactory. - ImageLoaderFactory decides the final URL:
- If the image resides in Supabase Storage, it generates a signed URL with expiration.
- If it resides in Cloudinary, it applies transformations (
f_auto,q_auto).
- Edge CDN → delivers the optimized version (WebP/AVIF) according to the
Acceptheader. - Cache → controls revalidation via
Cache-Control: public, max‑age=31536000, immutable.
3. Practical Implementation
Below is the evolution of an image‑optimization solution in Next.js, from a naïve implementation to an optimized and extensible architecture.
3.1 Basic approach: static <img>
// pages/product/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchProduct } from '../../lib/api';
type Props = {
product: {
id: string;
name: string;
imageUrl: string; // Direct URL to S3
};
};
export default function ProductPage({ product }: Props) {
return (
<section>
<h1>{product.name}</h1>
{/* Image without optimization */}
<img src={product.imageUrl} alt={product.name} width={800} height={600} />
</section>
);
}
// ... getStaticProps / getStaticPaths omitted for brevity
Problems:
- No
srcset, so the browser always downloads the same resolution. - No lazy loading.
- No WebP/AVIF format is served.
3.2 First leap: using Next.js <Image> component
// 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; // Direct URL to S3
};
};
export default function ProductPage({ product }: Props) {
return (
<section>
<h1>{product.name}</h1>
{/* Next.js Image enables srcset, lazy loading, and adaptive formats */}
<Image
src={product.imageUrl}
alt={product.name}
width={800}
height={600}
placeholder="blur"
blurDataURL="/placeholder.png"
quality={80}
/>
</section>
);
}
Advantages:
- Automatically generates
srcset. - Lazy loading by default (
loading="lazy"). - Applies quality optimization (
quality).
Limitations:
- The default loader only works with domains declared in
next.config.js. - No control over transformations (crop, watermark).
3.3 Custom loader with Factory (intermediate version)
// lib/imageLoader.ts
import { URL } from 'url';
/**
* Loader interface. Each implementation must return an optimized URL
* that includes quality and format parameters.
*/
export interface ImageLoader {
buildUrl(src: string, width: number, quality: number): string;
}
/**
* Loader for Supabase Storage (uses query params for transformation).
*/
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 or Supabase can decide
return url.toString();
}
}
/**
* Loader for Cloudinary (applies 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 that returns the appropriate implementation based on the environment variable.
*/
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 to 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 that delegates URL construction to the loader factory.
*/
export const OptimizedImage = (props: ImageProps) => {
const { src, width = 800, quality = 75, ...rest } = props;
const loader = ImageLoaderFactory.getLoader();
// Next.js allows passing a custom `loader` function.
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 (using the 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; // Relative path in the 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:
- Pros: Centralizes URL‑generation logic, allows swapping providers without touching components.
- Cons: Adds an abstraction layer that increases initial complexity.
3.4 Build‑time optimization: next-optimized-images (deprecated) → next-image-export-optimizer
Although the next-optimized-images plugin is deprecated, the community has adopted next-image-export-optimizer to generate static AVIF and WebP versions during the build.
# .bashrc (or CI script)
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: '/**',
},
],
},
// Integration of the optimizer in the export phase
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: {
// Generate AVIF and WebP, keep JPEG as fallback
formats: ['avif', 'webp'],
quality: 80,
// Common breakpoint widths
widths: [320, 640, 960, 1280, 1600, 1920],
},
},
],
});
}
return config;
},
});
Benefits:
- Images are processed once in CI, reducing load on the Edge.
- The resulting files are served as static assets, allowing
Cache-Control: immutable.
Considerations:
- The build process becomes more expensive (CPU‑intensive). It is recommended to run it on runners with dedicated resources.
3.5 Advanced strategy: Edge Middleware + Dynamic Image Service (Node.js)
For cases where an image needs dynamic transformations (crop, overlay, branding), you can expose an API Route that acts as an image micro‑service, running on the Edge.
// src/pages/api/_image.ts
import type { NextRequest } from 'next/server';
import sharp from 'sharp';
import { supabaseClient } from '../../lib/supabase';
// Singleton for the Supabase client (avoids reconnections)
let supabase = supabaseClient;
/**
* Middleware that receives query parameters:
* ?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️⃣ Fetch the original image from Supabase Storage
const { data, error } = await supabase.storage.from('images').download(src);
if (error || !data) {
return new Response('Image not found', { status: 404 });
}
// 2️⃣ Transform with Sharp (crop, resize, format conversion)
const transformed = await sharp(await data.arrayBuffer())
.resize({ width, fit })
.toFormat(format, { quality: 85 })
.toBuffer();
// 3️⃣ Respond with cache headers
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 that points to the Edge API for dynamic transformations.
*/
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"
/>
);
};
Advantages of this architecture:
| Aspect | Static approach (next‑image‑export‑optimizer) | Edge Middleware + Sharp |
|---|---|---|
| Latency | Very low (CDN serves pre‑generated file) | Moderate (processing at the edge) |
| Flexibility | Limited to predefined breakpoints and formats | Unlimited dynamic transformations |
| Build cost | High (CPU‑intensive during CI) | Low (runtime cost spread across edge nodes) |
| Cacheability | Immutable static assets | Can still be cached, but varies per request parameters |
