IAMUVIN

Next.js & React

Next.js Image Component Deep Dive: Beyond the Basics

Uvin Vindula·November 4, 2024·10 min read
Share

TL;DR

The next/image component is the single biggest performance win in most Next.js applications, but the majority of developers stop at the basics. I have shipped dozens of production sites and never once used a raw <img> tag in a Next.js project. In this deep dive, I cover everything beyond the getting-started docs: how the sizes prop actually controls image delivery (and why most implementations are wrong), configuring external domains without security holes, generating blur placeholders that do not block rendering, using the priority prop strategically, building responsive images with proper srcSet behavior, and integrating Sanity CMS image URLs with on-the-fly transformations. I also share real Lighthouse numbers showing the difference between naive and optimized image implementations. If you are using next/image but have never touched the sizes prop, this article will change how you think about images on the web.


Why next/image Exists

Before diving into the advanced patterns, it is worth understanding why this component exists in the first place. The raw HTML <img> tag is one of the worst performance offenders on the modern web. It does not resize images to match the viewport. It does not convert to modern formats like AVIF or WebP. It does not lazy load by default. It does not reserve space to prevent layout shifts.

Every single one of these problems costs you Core Web Vitals points, and Core Web Vitals directly affect search rankings.

The next/image component solves all of them in one abstraction:

  • Automatic format conversion. AVIF where supported, WebP as fallback, original format as last resort.
  • On-demand resizing. Images are resized on the server at request time, so you never send a 4000px image to a 400px container.
  • Lazy loading by default. Images below the fold only load when they enter the viewport.
  • Layout shift prevention. The component reserves the exact aspect ratio before the image loads, giving you a CLS of zero for images.
  • Blur placeholder support. You can show a blurred preview while the full image loads, which makes perceived performance feel instant.

I do not use raw <img> tags. Ever. Even for tiny icons, I use next/image because the consistency and automatic optimization are worth the minimal overhead. The only exception is SVGs rendered inline via a React component, and even then I import them through next/image when they are used as <img> sources.


The Basics Done Right

Let me start with the patterns I see most developers get right, but with subtle mistakes that cost performance.

Static Imports

The cleanest way to use next/image with local files:

tsx
import Image from 'next/image';
import heroImage from '@/public/images/hero.jpg';

export function Hero() {
  return (
    <Image
      src={heroImage}
      alt="Product showcase on dark background"
      placeholder="blur"
    />
  );
}

When you import an image statically, Next.js automatically determines the width, height, and generates a blurDataURL at build time. You get blur placeholders for free. No additional configuration. No manual dimension calculation.

The mistake I see: developers importing images statically but then overriding the dimensions with arbitrary values, which breaks the aspect ratio and causes layout shifts.

Fill Mode

For images that need to fill their parent container:

tsx
export function ProductCard({ product }: { product: Product }) {
  return (
    <div className="relative aspect-video overflow-hidden rounded-lg">
      <Image
        src={product.imageUrl}
        alt={product.name}
        fill
        className="object-cover"
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
    </div>
  );
}

Three things to note here. First, the parent must have position: relative (or absolute or fixed). Second, you almost always want object-cover or object-contain alongside fill. Third — and this is critical — the sizes prop is required when using fill mode. If you omit it, Next.js defaults to 100vw, which means every image is served at the full viewport width. On a grid of product cards, that means loading 4000px images for 300px thumbnails.


sizes Prop -- Most People Get This Wrong

The sizes prop is the most misunderstood prop in the entire next/image API. I have reviewed hundreds of Next.js codebases and the majority either omit it entirely or use incorrect values.

Here is what sizes actually does: it tells the browser how wide the image will be rendered at different viewport widths, so the browser can pick the right source from the generated srcSet. Without it, the browser assumes the image takes up the full viewport width and downloads the largest version.

The Wrong Way

tsx
// This is wrong. 100vw for a sidebar image?
<Image src={src} alt={alt} fill sizes="100vw" />

The Right Way

Think about your actual layout. If an image sits in a three-column grid on desktop, two-column on tablet, and full-width on mobile:

tsx
<Image
  src={src}
  alt={alt}
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

The Math

Let me break down what happens on a 1440px desktop viewport with this sizes value:

  1. None of the media conditions match until we get to the default: 33vw.
  2. 33% of 1440px is approximately 475px.
  3. Next.js has generated srcSet entries at various widths (640, 750, 828, 1080, 1200, etc.).
  4. The browser picks the 640px variant (accounting for device pixel ratio on a standard display) or the 1080px variant on a Retina display.

Without the sizes prop, the browser would download the 1440px or 2880px variant. That is 3-5x more data than needed.

Real-World Example: Blog Post Layout

On iamuvin.com, my blog post layout has a content area that maxes out at 768px, with padding. The actual image rendering width is never more than 720px. Here is my sizes prop:

tsx
<Image
  src={post.coverImage}
  alt={post.title}
  fill
  sizes="(max-width: 768px) 100vw, 720px"
/>

This says: on mobile, the image is full-width. On anything wider than 768px, the image is exactly 720px. The browser will never download an image wider than 1440px (720px times 2 for Retina), regardless of viewport size. On a 4K monitor, you save over 60% in image data compared to sizes="100vw".


External Images -- Configuring Domains

If your images come from an external source — a CMS, a CDN, user uploads — you need to configure Next.js to allow those domains. Without this, you get a runtime error that is surprisingly unhelpful.

next.config.js Configuration

js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
      },
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
      {
        protocol: 'https',
        hostname: '*.supabase.co',
        pathname: '/storage/v1/object/public/**',
      },
    ],
  },
};

module.exports = nextConfig;

A few things I have learned the hard way:

Use `remotePatterns` instead of the deprecated `domains` array. The domains property only matches hostnames. remotePatterns lets you restrict by protocol, hostname, port, and pathname. This is a security improvement — you can ensure only images from specific paths on a CDN are allowed, not arbitrary files.

Use wildcard patterns sparingly. The * in *.supabase.co matches any Supabase project. In production, I restrict this to my specific project subdomain:

js
{
  protocol: 'https',
  hostname: 'abcdefghij.supabase.co',
  pathname: '/storage/v1/object/public/**',
}

The pathname pattern matters. Without restricting the pathname on Supabase, someone could theoretically reference private storage paths through your image optimization endpoint. Lock it down to public paths.


Blur Placeholders

Blur placeholders are the difference between a page that feels fast and a page that feels like it is loading. When the user sees a blurred preview of the image before the full resolution loads, perceived performance improves dramatically even if the actual load time is identical.

Static Images: Free Blur

For statically imported images, blur placeholders are automatic:

tsx
import coverImage from '@/public/images/cover.jpg';

<Image src={coverImage} alt="Cover" placeholder="blur" />

Next.js generates a tiny (around 8x8 pixel) base64-encoded version at build time and inlines it. Zero runtime cost. Zero additional requests.

Dynamic Images: Manual blurDataURL

For external or dynamic images, you need to provide the blurDataURL yourself. Here is my approach:

tsx
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 BlogCover({ imageUrl, title }: BlogCoverProps) {
  const blurDataURL = await getBlurDataURL(imageUrl);

  return (
    <Image
      src={imageUrl}
      alt={title}
      width={1200}
      height={630}
      placeholder="blur"
      blurDataURL={blurDataURL}
      priority
    />
  );
}

The plaiceholder library generates the blur hash efficiently. I set size: 10 which produces a 10x10 pixel blur — slightly more detail than the default 8x8, but still under 1KB as a base64 string.

Important: Generate blur data URLs at build time or during ISR revalidation, not on every request. In my projects, I store the generated blurDataURL alongside the image reference in my CMS or database. When I upload an image to Sanity, a webhook generates the blur hash and saves it to a custom field.

The color Placeholder Alternative

If generating blur hashes is too much overhead, use a solid color placeholder:

tsx
<Image
  src={imageUrl}
  alt={alt}
  width={800}
  height={600}
  placeholder="color"
  style={{ backgroundColor: '#1A2236' }}
/>

This reserves the space and shows a solid color instead of a content shift. Not as polished as a blur, but better than nothing.


Priority for Above-Fold

The priority prop is your Largest Contentful Paint (LCP) weapon. By default, next/image lazy loads everything. For images that appear above the fold — hero images, the first product image, your blog post cover — you want eager loading.

tsx
<Image
  src={heroImage}
  alt="Hero banner"
  fill
  priority
  sizes="100vw"
/>

When priority is set:

  1. The image loads eagerly (no Intersection Observer).
  2. A <link rel="preload"> tag is added to the document head.
  3. Fetch priority is set to high.

Rules I Follow

  • Only one `priority` image per page. If everything is priority, nothing is. The browser can only preload so many resources before it becomes counterproductive.
  • Never use `priority` on images below the fold. It defeats the purpose of lazy loading and wastes bandwidth on images the user may never scroll to.
  • Always use `priority` on LCP candidates. Run a Lighthouse test, identify your LCP element, and if it is an image, add priority. This single change has shaved 200-800ms off LCP on multiple projects.

On EuroParts Lanka, the hero image was the LCP element. Adding priority dropped LCP from 3.1 seconds to 2.3 seconds. That alone moved the Lighthouse performance score from 78 to 89.


Responsive Images with srcSet

When you provide width and height to next/image, it generates a srcSet with multiple resolutions automatically. The default device sizes are [640, 750, 828, 1080, 1200, 1920, 2048, 3840] and the default image sizes are [16, 32, 48, 64, 96, 128, 256, 384].

You can customize these in next.config.js:

js
const nextConfig = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

When to Customize

I rarely change deviceSizes. The defaults cover the most common viewport widths. But imageSizes is worth adjusting if you have a design system with specific thumbnail sizes:

js
const nextConfig = {
  images: {
    imageSizes: [40, 48, 64, 96, 128, 192, 256, 384],
  },
};

If your product thumbnails are always 192px wide, adding 192 to imageSizes ensures the browser gets an exact match instead of downloading the next size up (256px) and wasting bandwidth.

Art Direction with the picture Element

For true art direction — showing different crops at different viewports — next/image alone is not enough. You need a wrapper:

tsx
export function ResponsiveHero() {
  return (
    <picture>
      <source
        media="(max-width: 640px)"
        srcSet="/images/hero-mobile.avif"
        type="image/avif"
      />
      <source
        media="(max-width: 640px)"
        srcSet="/images/hero-mobile.webp"
        type="image/webp"
      />
      <Image
        src="/images/hero-desktop.jpg"
        alt="Hero image"
        width={1920}
        height={800}
        priority
        sizes="100vw"
      />
    </picture>
  );
}

This gives you art direction while keeping next/image's optimization for the desktop variant. For the mobile sources, I pre-generate AVIF and WebP versions as part of my build pipeline.


Sanity + next/image Integration

Sanity CMS is my go-to for content management, and its image pipeline is exceptionally powerful. The key is combining Sanity's on-the-fly image transformations with next/image's optimization.

The Image URL Builder

tsx
import imageUrlBuilder from '@sanity/image-url';
import { client } from '@/lib/sanity';

const builder = imageUrlBuilder(client);

export function urlFor(source: SanityImageSource) {
  return builder.image(source);
}

Using Sanity Images with next/image

tsx
import Image from 'next/image';
import { urlFor } from '@/lib/sanity-image';

interface SanityImageProps {
  image: SanityImageSource;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
}

export function SanityImage({
  image,
  alt,
  width,
  height,
  priority = false,
}: SanityImageProps) {
  const imageUrl = urlFor(image)
    .width(width)
    .height(height)
    .quality(80)
    .auto('format')
    .url();

  const blurUrl = urlFor(image)
    .width(20)
    .height(Math.round((20 * height) / width))
    .quality(30)
    .blur(50)
    .url();

  return (
    <Image
      src={imageUrl}
      alt={alt}
      width={width}
      height={height}
      placeholder="blur"
      blurDataURL={blurUrl}
      priority={priority}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

Key Details

Let Sanity handle the initial resize, let Next.js handle format conversion. I request a specific width and height from Sanity's CDN, and then next/image converts to AVIF/WebP. This avoids double-processing while getting the best of both systems.

Use Sanity's blur transform for placeholders. Instead of using plaiceholder to generate blur hashes at build time, I use Sanity's blur(50) parameter to get a server-side blurred version. The URL itself becomes the blurDataURL. This is a tiny image (20px wide) so the download is negligible.

Set `auto('format')` on the Sanity URL. This lets Sanity serve WebP when the browser supports it, before Next.js does its own format negotiation. Belt and suspenders.

Configure the Sanity CDN domain:

js
// next.config.js
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
        pathname: '/images/**',
      },
    ],
  },
};

Common Mistakes

After reviewing more codebases than I can count, here are the mistakes I see repeatedly:

1. Missing sizes Prop on fill Images

tsx
// Wrong: defaults to 100vw, downloads massive images
<Image src={url} alt={alt} fill />

// Right: match your actual layout
<Image src={url} alt={alt} fill sizes="(max-width: 768px) 100vw, 33vw" />

2. Using width/height as Styling Props

tsx
// Wrong: these control the intrinsic size, not the rendered size
<Image src={url} alt={alt} width={100} height={100} />

// Right: use CSS for rendered size, width/height for aspect ratio
<Image
  src={url}
  alt={alt}
  width={400}
  height={400}
  className="w-24 h-24"
/>

The width and height props determine the aspect ratio and the base size for srcSet generation. Use CSS (or Tailwind classes) for the actual rendered dimensions.

3. priority on Every Image

I have seen components where every single <Image> has priority. This is worse than having no priority at all because you are telling the browser that everything is critical, which means nothing is prioritized effectively.

4. Not Handling Loading States

When images take time to load, users see nothing until the full image arrives. Always use a placeholder:

tsx
<Image
  src={url}
  alt={alt}
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL={blurDataURL}
/>

5. Ignoring the quality Prop

The default quality is 75, which is fine for most cases. But for hero images and product photography, I bump it to 85:

tsx
<Image src={heroUrl} alt={alt} fill priority quality={85} sizes="100vw" />

For thumbnails and decorative images, I drop it to 60:

tsx
<Image src={thumbUrl} alt={alt} width={200} height={200} quality={60} />

6. Using next/image for SVGs Without Configuration

By default, next/image will try to optimize SVGs, which can break them. Configure SVGs as unoptimized:

tsx
<Image src="/icons/logo.svg" alt="Logo" width={120} height={40} unoptimized />

Or globally in your config:

js
const nextConfig = {
  images: {
    dangerouslyAllowSVG: true,
    contentDispositionType: 'attachment',
    contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
  },
};

Performance Impact -- Real Numbers

I do not believe in performance advice without data. Here are real numbers from three production projects where I optimized image delivery.

EuroParts Lanka (E-commerce)

Product catalog with 2,400+ images across categories.

MetricBefore OptimizationAfter OptimizationImprovement
Total Image Weight (Homepage)4.2 MB1.1 MB-74%
LCP3.1s2.3s-26%
CLS0.180.01-94%
Lighthouse Performance7892+14 points

Changes: Added sizes prop to all product grid images, priority on hero image, blur placeholders for product cards, AVIF delivery.

FreshMart UK (Grocery Platform)

Recipe pages with high-resolution food photography.

MetricBefore OptimizationAfter OptimizationImprovement
Total Image Weight (Recipe Page)6.8 MB1.9 MB-72%
LCP3.8s2.1s-45%
CLS0.240.0-100%
Lighthouse Performance7194+23 points

Changes: Sanity image integration with proper dimensions, sizes prop matching the recipe layout grid, blur placeholders from Sanity's CDN, quality tuning per image context.

iamuvin.com (This Site)

Blog with cover images and inline article images.

MetricBefore OptimizationAfter OptimizationImprovement
Total Image Weight (Blog Post)2.1 MB0.6 MB-71%
LCP2.6s1.8s-31%
CLS0.050.0-100%
Lighthouse Performance8597+12 points

Changes: Static imports for all local images (free blur), sizes="(max-width: 768px) 100vw, 720px" for content images, priority on cover image only.

The Pattern

Across all three projects, the sizes prop was responsible for the majority of the image weight reduction. The priority prop drove most of the LCP improvement. Blur placeholders eliminated CLS. These three props — sizes, priority, and placeholder — account for 90% of the performance gains.


Key Takeaways

  1. Never use a raw `<img>` tag in Next.js. The next/image component handles format conversion, lazy loading, responsive sizing, and layout shift prevention automatically.
  1. The `sizes` prop is not optional. If you use fill mode or want optimal image delivery, you must tell the browser how wide your image will actually render. Default 100vw wastes bandwidth on every image that is not full-width.
  1. One `priority` image per page. Identify your LCP element. If it is an image, add priority. Do not add it to anything else.
  1. Generate blur placeholders at build time. For static images, use placeholder="blur" with static imports. For dynamic images, use plaiceholder or your CMS's blur capabilities and cache the result.
  1. Configure `remotePatterns` with least privilege. Restrict by hostname, protocol, and pathname. Never use wildcards in production without a good reason.
  1. Match your `sizes` prop to your actual layout. Inspect your CSS breakpoints. Calculate the real image width at each breakpoint. Write the sizes prop to match reality, not approximation.
  1. Combine CMS transformations with Next.js optimization. Let Sanity (or your CMS) handle cropping and initial sizing. Let Next.js handle format conversion and device-specific delivery.
  1. Measure before and after. Run Lighthouse. Check the Network tab. Compare total image weight. Performance optimization without measurement is just guessing.

About the Author

I am Uvin Vindula, a Web3 and AI engineer based between Sri Lanka and the UK. I build production-grade applications with Next.js, React, TypeScript, and the modern web stack. I have strong opinions about image optimization because I have seen the difference it makes on real projects with real users on real connections — including markets like Sri Lanka where bandwidth is not unlimited and every kilobyte matters.

If you are building a Next.js application and want to get performance right from the start, check out my services or reach out at contact@uvin.lk.


*Published on iamuvin.com by Uvin Vindula (@IAMUVIN)*

Working on a Web3 or AI project?

Share
Uvin Vindula

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.