Performance & Optimization
Image Optimization in Next.js: The Complete Performance Guide
TL;DR
Images are the single biggest performance bottleneck on most websites. On FreshMart, images accounted for over 70% of the total page weight before I optimized them. After switching every image to next/image with strict rules — WebP/AVIF format negotiation, blur placeholders, explicit dimensions, lazy loading below the fold, and priority flags on hero images — total image payload dropped from 4.2 MB to 380 KB and the PageSpeed score jumped from 62 to 97 on mobile. This article covers every pattern I use, the reasoning behind each decision, and the exact component abstractions I reach for on every project I ship through my services.
Why Images Kill Performance
Images are not just big files. They are the root cause of nearly every performance metric failure I see when auditing client sites.
Here is what unoptimized images actually do to your Core Web Vitals:
- LCP blows up. The Largest Contentful Paint element is an image on 70%+ of web pages. If that image is a 2 MB uncompressed PNG loading from a slow CDN with no responsive sizing, your LCP is going to be 5+ seconds on mobile. Google marks you as "poor" at 2.5 seconds.
- CLS goes haywire. When images load without explicit
widthandheight, the browser cannot reserve space for them. The layout shifts as each image pops in. Users click the wrong button. Google penalizes you with a CLS score above 0.1. - Total Blocking Time increases. The browser's main thread gets tied up decoding large images. On mid-range mobile devices, decoding a 4000x3000 JPEG takes real time — time that blocks interaction handlers.
- Data costs punish mobile users. A 5 MB hero image on a page serving users in Sri Lanka or India over 4G is not just slow — it is expensive for the user. This is a UX failure that no amount of animation polish can fix.
I have audited dozens of Next.js sites where the developer used plain <img> tags, loaded full-resolution images at every viewport width, and never set explicit dimensions. The fix is not complicated, but it requires discipline.
next/image — What It Actually Does
The next/image component is not just a fancy <img> wrapper. It is an image pipeline built into your framework. Here is what happens when you use it:
- Automatic format negotiation. Next.js checks the browser's
Acceptheader and serves WebP or AVIF if the browser supports it, falling back to the original format otherwise. No manual conversion needed. - On-demand resizing. Images are resized at request time based on the
sizesprop and the user's viewport. A 4000px source image gets served as a 640px image to a mobile user. The resized version is cached for subsequent requests. - Lazy loading by default. Every image below the fold gets
loading="lazy"and is only fetched when it enters the viewport. No intersection observer code needed. - Blur placeholder support. You can generate a tiny blurred version of the image that displays instantly while the full image loads. This eliminates the jarring pop-in effect.
- CLS prevention. When you provide
widthandheight(or usefill), Next.js calculates the aspect ratio and reserves the exact space in the layout before the image loads. Zero layout shift.
Here is the minimal usage:
import Image from 'next/image';
export function ProductCard({ product }: { product: Product }) {
return (
<div className="relative aspect-square overflow-hidden rounded-lg">
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={400}
className="object-cover"
/>
</div>
);
}This single component gives you format negotiation, lazy loading, responsive resizing, and CLS prevention out of the box. But the defaults only get you halfway. The real performance gains come from the configuration.
Sizing and Responsive Images
The most common mistake I see is using next/image without a sizes prop. Without it, Next.js generates a default srcset but the browser has no information about how large the image will actually be rendered. It guesses. It guesses wrong.
The sizes prop tells the browser: "At this viewport width, this image will be this wide." The browser then picks the smallest image from the srcset that covers that width.
<Image
src="/products/fresh-salmon.jpg"
alt="Fresh Atlantic Salmon Fillet"
width={800}
height={600}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>This tells the browser:
- On mobile (up to 640px viewport): the image takes the full viewport width.
- On tablet (up to 1024px): the image takes half the viewport.
- On desktop: the image takes a third of the viewport.
On a 375px wide iPhone, the browser will request a ~375px wide image instead of the full 800px source. That is a 4x reduction in file size for a single image.
For the fill layout (when the image fills its parent container), sizing becomes even more important:
<div className="relative h-64 w-full">
<Image
src="/hero/homepage-banner.jpg"
alt="FreshMart — Fresh groceries delivered to your door"
fill
sizes="100vw"
className="object-cover"
priority
/>
</div>I configure the deviceSizes and imageSizes in next.config.ts to match the breakpoints I actually use:
// next.config.ts
const nextConfig = {
images: {
deviceSizes: [640, 768, 1024, 1280, 1536],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
export default nextConfig;The rule I follow: every `next/image` in a project must have an explicit `sizes` prop unless it is a fixed-size avatar or icon. No exceptions.
Format Selection — WebP vs AVIF
Next.js supports both WebP and AVIF, and it negotiates automatically based on the browser's Accept header. But which format should you optimize for?
Here is the breakdown:
| Format | Compression | Browser Support | Encoding Speed | Best For |
|---|---|---|---|---|
| JPEG | Baseline | Universal | Fast | Fallback only |
| WebP | ~25-35% smaller than JPEG | 97%+ browsers | Fast | Default choice |
| AVIF | ~40-50% smaller than JPEG | 92%+ browsers | Slow | Maximum compression |
I configure Next.js to prefer AVIF with WebP fallback:
// next.config.ts
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
},
};
export default nextConfig;The order matters. Next.js tries AVIF first. If the browser does not support it, it falls back to WebP. If neither is supported, it serves the original format.
There is a trade-off. AVIF encoding is significantly slower than WebP — roughly 10-20x slower on the first request. This means the first user to request an AVIF image at a given size will experience a longer response time while the server encodes it. After that, the result is cached.
My rule: use AVIF + WebP on production sites where the cache is warm. For development or low-traffic pages where cold cache hits are common, WebP alone is fine:
// For low-traffic sites or development
const nextConfig = {
images: {
formats: ['image/webp'],
},
};On FreshMart, switching from WebP-only to AVIF-first reduced the average image payload by an additional 18% with no visible quality loss at quality 75.
Blur Placeholders
Nothing looks cheaper than a white rectangle snapping into an image. Blur placeholders solve this by showing a tiny, blurred preview of the image instantly while the full version loads.
For local images (imported statically), Next.js generates the blur placeholder automatically at build time:
import heroImage from '@/public/images/hero-banner.jpg';
export function HeroBanner() {
return (
<Image
src={heroImage}
alt="FreshMart homepage banner"
placeholder="blur"
priority
sizes="100vw"
className="object-cover"
/>
);
}That is it. The placeholder="blur" prop works automatically with static imports because Next.js generates the blurDataURL at build time.
For remote images (from a CMS, CDN, or API), you need to generate the blur data URL yourself. I use plaiceholder for this:
import { getPlaiceholder } from 'plaiceholder';
async function getBlurDataUrl(imageUrl: string): Promise<string> {
const response = await fetch(imageUrl);
const buffer = Buffer.from(await response.arrayBuffer());
const { base64 } = await getPlaiceholder(buffer, { size: 10 });
return base64;
}
export async function ProductHero({ imageUrl }: { imageUrl: string }) {
const blurDataURL = await getBlurDataUrl(imageUrl);
return (
<Image
src={imageUrl}
alt="Product hero image"
width={1200}
height={630}
placeholder="blur"
blurDataURL={blurDataURL}
priority
sizes="100vw"
/>
);
}Since this is a Server Component, the blur generation happens at request time on the server. For pages with many images, I generate blur data URLs in parallel:
const products = await getProducts();
const productsWithBlur = await Promise.all(
products.map(async (product) => ({
...product,
blurDataURL: await getBlurDataUrl(product.imageUrl),
}))
);The blur placeholder adds roughly 200-400 bytes per image as a base64-encoded string inlined in the HTML. That is a negligible cost for a dramatically better perceived loading experience.
Priority Loading for Hero Images
By default, next/image lazy-loads everything. This is correct for images below the fold, but it is exactly wrong for the hero image or the LCP element.
The priority prop tells Next.js to:
- Remove
loading="lazy"from the image. - Add a
<link rel="preload">tag in the document<head>for the image. - Fetch the image immediately, before the browser encounters it during parsing.
<Image
src="/hero/main-banner.jpg"
alt="Welcome to FreshMart"
width={1920}
height={1080}
sizes="100vw"
priority
placeholder="blur"
blurDataURL={blurDataUrl}
/>My strict rule: exactly one image per page gets `priority`. It is always the hero image or the LCP element. If you set priority on multiple images, you are preloading multiple large assets and defeating the purpose.
On FreshMart, adding priority to the homepage hero image dropped LCP from 3.1 seconds to 1.4 seconds on mobile. That single prop change was the largest single improvement in the entire optimization pass.
For pages where the hero is a carousel or dynamic, I set priority only on the first slide:
{slides.map((slide, index) => (
<Image
key={slide.id}
src={slide.imageUrl}
alt={slide.alt}
fill
sizes="100vw"
priority={index === 0}
className="object-cover"
/>
))}External Images — Sanity and Cloudinary
Most production sites load images from a headless CMS or an image CDN. Next.js requires you to whitelist external domains in the config.
Here is how I configure it for Sanity and Cloudinary:
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/your-cloud-name/image/**',
},
],
},
};
export default nextConfig;Use remotePatterns instead of the older domains config. It gives you path-level control so you only allow image paths, not arbitrary content from those domains.
For Sanity, I use the @sanity/image-url builder to generate optimized URLs and then pass them through next/image for format negotiation and sizing:
import imageUrlBuilder from '@sanity/image-url';
import { client } from '@/lib/sanity';
const builder = imageUrlBuilder(client);
function urlFor(source: SanityImageSource) {
return builder.image(source);
}
export function SanityImage({ image, alt, priority = false }: SanityImageProps) {
const imageUrl = urlFor(image)
.width(1200)
.quality(80)
.auto('format')
.url();
return (
<Image
src={imageUrl}
alt={alt}
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
priority={priority}
/>
);
}For Cloudinary, you can use Next.js's built-in loader or configure a custom one:
// next.config.ts
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/cloudinary-loader.ts',
},
};// lib/cloudinary-loader.ts
interface CloudinaryLoaderProps {
src: string;
width: number;
quality?: number;
}
export default function cloudinaryLoader({
src,
width,
quality = 75,
}: CloudinaryLoaderProps): string {
const params = [
'f_auto',
'c_limit',
`w_${width}`,
`q_${quality}`,
];
return `https://res.cloudinary.com/your-cloud-name/image/upload/${params.join(',')}${src}`;
}This lets Cloudinary handle the transformation while next/image handles the responsive srcset and lazy loading logic.
Avoiding CLS from Images
Cumulative Layout Shift from images happens when the browser does not know the image dimensions before it loads. The content below the image shifts down once the image appears. Google penalizes this heavily.
There are three patterns I use to guarantee zero CLS from images:
Pattern 1: Explicit width and height
The simplest approach. Always works.
<Image
src="/products/organic-avocado.jpg"
alt="Organic Hass Avocado"
width={400}
height={300}
/>Next.js uses these dimensions to calculate the aspect ratio and reserves the space in the layout.
Pattern 2: Fill with aspect-ratio container
When you need the image to fill a responsive container:
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-lg">
<Image
src={imageUrl}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>The aspect-[16/9] class on the parent locks the container's aspect ratio. The fill prop makes the image cover that container. Zero layout shift.
Pattern 3: Blur placeholder as visual anchor
Even with dimensions set, there is a perceived shift when a white box becomes an image. The blur placeholder eliminates this by filling the space with a color-matched preview immediately:
<Image
src={product.image}
alt={product.name}
width={400}
height={400}
placeholder="blur"
blurDataURL={product.blurDataURL}
className="object-cover"
/>On FreshMart, the product grid had 24 images per page. Without explicit dimensions and blur placeholders, the CLS score was 0.34 — more than triple the "poor" threshold. After implementing Pattern 2 with blur placeholders, CLS dropped to 0.01.
My Image Component Patterns
I do not use next/image directly across a project. I wrap it in purpose-built components that enforce my rules automatically. Here are the three components I use on every project:
OptimizedImage — The general-purpose wrapper
import Image, { type ImageProps } from 'next/image';
interface OptimizedImageProps extends Omit<ImageProps, 'alt'> {
alt: string; // Force alt to be required and non-optional
}
export function OptimizedImage({ alt, sizes, ...props }: OptimizedImageProps) {
if (!sizes && !props.fill) {
console.warn(
`[OptimizedImage] Missing "sizes" prop for image: ${props.src}`
);
}
return (
<Image
alt={alt}
sizes={sizes}
quality={75}
{...props}
/>
);
}This wrapper does two things: it forces alt text to be a required string (not an optional prop you forget), and it warns in development when sizes is missing.
HeroImage — Priority loading with blur
interface HeroImageProps {
src: string;
alt: string;
blurDataURL?: string;
}
export function HeroImage({ src, alt, blurDataURL }: HeroImageProps) {
return (
<div className="relative h-[60vh] min-h-[400px] w-full overflow-hidden">
<Image
src={src}
alt={alt}
fill
sizes="100vw"
priority
quality={80}
placeholder={blurDataURL ? 'blur' : 'empty'}
blurDataURL={blurDataURL}
className="object-cover"
/>
</div>
);
}This component bakes in every rule for hero images: priority is always on, sizes is always 100vw, quality is slightly higher at 80, and blur placeholder is enabled when data is available.
ProductImage — Grid-optimized with aspect ratio lock
interface ProductImageProps {
src: string;
alt: string;
blurDataURL?: string;
aspectRatio?: string;
}
export function ProductImage({
src,
alt,
blurDataURL,
aspectRatio = '1/1',
}: ProductImageProps) {
return (
<div
className="relative w-full overflow-hidden rounded-lg bg-gray-100"
style={{ aspectRatio }}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
quality={75}
placeholder={blurDataURL ? 'blur' : 'empty'}
blurDataURL={blurDataURL}
className="object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
);
}The bg-gray-100 background ensures there is never a white flash before the blur placeholder or image loads. The aspectRatio prop defaults to square for product grids but can be overridden for editorial layouts.
Real Before/After Numbers
Here are the actual numbers from the FreshMart optimization, measured on a Moto G Power over throttled 4G using WebPageTest:
Homepage (24 product images + hero banner)
| Metric | Before | After | Change |
|---|---|---|---|
| Total image weight | 4.2 MB | 380 KB | -91% |
| LCP | 5.8s | 1.4s | -76% |
| CLS | 0.34 | 0.01 | -97% |
| Speed Index | 6.2s | 2.1s | -66% |
| PageSpeed (mobile) | 62 | 97 | +56% |
Product detail page (1 hero + 4 gallery images)
| Metric | Before | After | Change |
|---|---|---|---|
| Total image weight | 3.1 MB | 290 KB | -91% |
| LCP | 4.2s | 1.1s | -74% |
| CLS | 0.18 | 0.00 | -100% |
| PageSpeed (mobile) | 71 | 99 | +39% |
What produced the biggest wins
- AVIF format negotiation — Reduced average image size by 60% vs the original JPEGs with no visible quality difference.
- Responsive `sizes` prop — Mobile users downloaded 75% less data because they received appropriately sized images instead of desktop-width files.
- `priority` on hero images — Single biggest LCP improvement. 2.4 seconds saved by preloading instead of lazy-loading the LCP element.
- Blur placeholders — Did not affect raw performance numbers, but eliminated perceived loading jank. User engagement metrics improved: bounce rate dropped 12% after the visual experience became smoother.
- Explicit dimensions on every image — Eliminated all image-related CLS. The 0.34 to 0.01 drop was almost entirely from adding
width/heightorfillwith aspect-ratio containers.
Key Takeaways
- Use `next/image` for every image. No exceptions. No plain
<img>tags. The automatic format negotiation and lazy loading alone justify it. - Always set `sizes`. Without it, the browser downloads images larger than it needs. This is the most overlooked prop in the entire Next.js ecosystem.
- Configure AVIF + WebP. Set
formats: ['image/avif', 'image/webp']in your config. The compression gains are massive. - One `priority` per page. Your hero image or LCP element. Everything else lazy loads.
- Blur placeholders everywhere. Use static imports for local images. Use
plaiceholderfor remote images. The perceived performance improvement is significant. - Explicit dimensions are non-negotiable. Either
width/heightorfillinside an aspect-ratio container. Zero tolerance for CLS from images. - Wrap `next/image` in project-specific components. Enforce your rules through abstraction. Let the component handle
sizes, quality, placeholder, and priority logic so individual developers cannot skip them. - Measure on real devices. Lighthouse on your M3 MacBook Pro is not representative. Test on a throttled mid-range Android over 4G. That is where your users are.
Images are not glamorous to optimize. There are no conference talks about setting the sizes prop correctly. But on every project I ship — through my services or my own products — image optimization consistently delivers the single largest performance improvement for the least amount of code.
Get the images right, and your Lighthouse score takes care of itself.
*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production-grade applications at iamuvin.com↗. He specializes in Next.js performance, smart contract development, and AI integration. See his work on FreshMart or explore his services.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.