Back to Blog

Next.js Image Optimization Deep Dive: Performance, Responsive Images, and Configuration

Introduction

The Next.js <Image> component provides automatic image optimization including format conversion (WebP/AVIF), responsive sizing, lazy loading, and blur-up placeholders. This guide covers the internals of image optimization, configuration options, and production patterns for maximizing Core Web Vitals.

Image Optimization Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                   NEXT.JS IMAGE OPTIMIZATION PIPELINE                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                      IMAGE REQUEST FLOW                              │   │
│  │                                                                      │   │
│  │  Browser Request                                                     │   │
│  │       │                                                              │   │
│  │       ▼                                                              │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │  /_next/image?url=...&w=640&q=75                             │   │   │
│  │  │                                                               │   │   │
│  │  │  Parameters:                                                  │   │   │
│  │  │  • url: Source image path                                     │   │   │
│  │  │  • w: Target width (from deviceSizes/imageSizes)              │   │   │
│  │  │  • q: Quality (1-100, from qualities array)                   │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  │       │                                                              │   │
│  │       ▼                                                              │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │                    OPTIMIZATION ENGINE                        │   │   │
│  │  │                                                               │   │   │
│  │  │  1. Check disk cache (LRU, configurable size)                 │   │   │
│  │  │  2. If cache miss:                                            │   │   │
│  │  │     a. Fetch source image                                     │   │   │
│  │  │     b. Detect browser format support (Accept header)          │   │   │
│  │  │     c. Resize to requested width                              │   │   │
│  │  │     d. Encode to WebP/AVIF/original                           │   │   │
│  │  │     e. Store in disk cache                                    │   │   │
│  │  │  3. Serve optimized image                                     │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  │       │                                                              │   │
│  │       ▼                                                              │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │  Response Headers                                             │   │   │
│  │  │  • Cache-Control: max-age=31536000 (static import)           │   │   │
│  │  │  • Content-Type: image/webp (or avif/jpeg/png)               │   │   │
│  │  │  • Content-Disposition: attachment (SVG security)             │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     GENERATED HTML OUTPUT                            │   │
│  │                                                                      │   │
│  │  <img                                                                │   │
│  │    srcset="/_next/image?url=...&w=640&q=75 640w,                    │   │
│  │            /_next/image?url=...&w=750&q=75 750w,                    │   │
│  │            /_next/image?url=...&w=828&q=75 828w,                    │   │
│  │            /_next/image?url=...&w=1080&q=75 1080w,                  │   │
│  │            /_next/image?url=...&w=1200&q=75 1200w"                  │   │
│  │    sizes="(max-width: 768px) 100vw, 50vw"                           │   │
│  │    src="/_next/image?url=...&w=1200&q=75"                           │   │
│  │    loading="lazy"                                                    │   │
│  │    decoding="async"                                                  │   │
│  │  />                                                                  │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

Core Image Component Usage

Local Images (Static Import)

// app/page.tsx
import Image from 'next/image'
import heroImage from './hero.png'  // Static import

export default function Page() {
  return (
    <Image
      src={heroImage}
      alt="Hero image"
      // width and height automatically inferred
      // blurDataURL automatically generated
      placeholder="blur"  // Shows blur while loading
    />
  )
}

Remote Images

// app/page.tsx
import Image from 'next/image'

export default function Page() {
  return (
    <Image
      src="https://cdn.example.com/photos/profile.jpg"
      alt="Profile photo"
      width={400}   // Required for remote images
      height={400}  // Required for remote images
    />
  )
}
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/photos/**',
      },
    ],
  },
}

export default nextConfig

Essential Props

Size and Layout

// Fixed size image
<Image
  src="/photo.jpg"
  alt="Photo"
  width={500}
  height={300}
/>

// Fill container (responsive)
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
  <Image
    src="/photo.jpg"
    alt="Photo"
    fill                    // Fills parent container
    style={{ objectFit: 'cover' }}  // cover, contain, none
  />
</div>

Responsive Images with sizes

// Hero image - full width on mobile, half on desktop
<Image
  src="/hero.jpg"
  alt="Hero"
  fill
  sizes="(max-width: 768px) 100vw, 50vw"
/>

// Grid item - third of viewport on large screens
<Image
  src="/product.jpg"
  alt="Product"
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

How sizes affects srcset:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        sizes PROP BEHAVIOR                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  WITHOUT sizes:                                                             │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  srcset generated for 1x and 2x density only                         │  │
│  │  Browser assumes image is 100vw                                      │  │
│  │  May download unnecessarily large images                             │  │
│  │                                                                       │  │
│  │  srcset="...&w=640 1x, ...&w=828 2x"                                 │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  WITH sizes="(max-width: 768px) 100vw, 50vw":                              │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │  Full srcset generated for all deviceSizes                           │  │
│  │  Browser selects optimal size based on viewport                      │  │
│  │  Significant bandwidth savings on large screens                      │  │
│  │                                                                       │  │
│  │  srcset="...&w=640 640w, ...&w=750 750w, ...&w=828 828w,            │  │
│  │          ...&w=1080 1080w, ...&w=1200 1200w, ..."                   │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Loading and Priority

// Above-the-fold hero image - preload
<Image
  src="/hero.jpg"
  alt="Hero"
  fill
  preload={true}        // Preloads via <link> in <head>
  loading="eager"       // Load immediately
/>

// Below-the-fold content - lazy load (default)
<Image
  src="/feature.jpg"
  alt="Feature"
  width={600}
  height={400}
  loading="lazy"        // Default - loads when entering viewport
/>

Placeholders

// Blur placeholder (auto-generated for static imports)
import photo from './photo.jpg'

<Image
  src={photo}
  alt="Photo"
  placeholder="blur"
/>

// Custom blur placeholder for remote images
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
/>

// Color placeholder
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/svg+xml;base64,PHN2ZyB4bWxucz0ia..."
/>

Generating blur placeholders:

// Using plaiceholder library
import { getPlaiceholder } from 'plaiceholder'
import fs from 'fs/promises'

async function getBlurDataURL(imagePath: string) {
  const buffer = await fs.readFile(imagePath)
  const { base64 } = await getPlaiceholder(buffer)
  return base64
}

// Simple color placeholder
function getColorPlaceholder(r: number, g: number, b: number) {
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect fill="rgb(${r},${g},${b})" width="1" height="1"/></svg>`
  return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}

Configuration Options

Complete Configuration Reference

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    // Remote image security
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.example.com',  // Wildcard subdomain
        pathname: '/images/**',
      },
    ],

    // Local image security
    localPatterns: [
      {
        pathname: '/assets/images/**',
      },
    ],

    // Device breakpoints for srcset generation
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],

    // Small image sizes (when sizes prop < smallest deviceSize)
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],

    // Allowed quality values (required in Next.js 16+)
    qualities: [25, 50, 75, 100],

    // Output formats (order matters - first supported wins)
    formats: ['image/avif', 'image/webp'],

    // Cache TTL in seconds
    minimumCacheTTL: 2678400,  // 31 days

    // Disk cache size limit in bytes
    maximumDiskCacheSize: 500_000_000,  // 500 MB

    // Max source image size
    maximumResponseBody: 50_000_000,  // 50 MB

    // Max redirects to follow
    maximumRedirects: 3,

    // Disable optimization globally
    unoptimized: false,

    // Custom image path
    path: '/_next/image',

    // SVG handling (security sensitive)
    dangerouslyAllowSVG: false,
    contentDispositionType: 'attachment',
    contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
  },
}

export default nextConfig

Custom Loader

// lib/image-loader.ts
'use client'

import type { ImageLoaderProps } from 'next/image'

export default function cloudinaryLoader({
  src,
  width,
  quality,
}: ImageLoaderProps) {
  const params = [
    'f_auto',
    'c_limit',
    `w_${width}`,
    `q_${quality || 'auto'}`,
  ]
  return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`
}
// next.config.ts
const nextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
}
// Or per-image loader
import Image from 'next/image'
import cloudinaryLoader from '@/lib/image-loader'

<Image
  loader={cloudinaryLoader}
  src="/photos/my-image.jpg"
  alt="Photo"
  width={800}
  height={600}
/>

Production Patterns

Pattern 1: Responsive Hero Image

// components/Hero.tsx
import Image from 'next/image'

interface HeroProps {
  title: string
  imageSrc: string
  imageAlt: string
}

export function Hero({ title, imageSrc, imageAlt }: HeroProps) {
  return (
    <section className="relative h-[60vh] min-h-[400px]">
      <Image
        src={imageSrc}
        alt={imageAlt}
        fill
        sizes="100vw"
        style={{ objectFit: 'cover' }}
        preload          // Critical LCP image
        loading="eager"
        quality={85}
      />

      {/* Dark overlay for text readability */}
      <div className="absolute inset-0 bg-black/40" />

      <div className="relative z-10 flex h-full items-center justify-center">
        <h1 className="text-5xl font-bold text-white">{title}</h1>
      </div>
    </section>
  )
}
// components/ProductGallery.tsx
'use client'

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

interface ProductGalleryProps {
  images: Array<{
    src: string
    alt: string
    blurDataURL?: string
  }>
}

export function ProductGallery({ images }: ProductGalleryProps) {
  const [selectedIndex, setSelectedIndex] = useState(0)

  return (
    <div className="flex flex-col gap-4">
      {/* Main image */}
      <div className="relative aspect-square w-full overflow-hidden rounded-lg">
        <Image
          src={images[selectedIndex].src}
          alt={images[selectedIndex].alt}
          fill
          sizes="(max-width: 768px) 100vw, 50vw"
          style={{ objectFit: 'contain' }}
          placeholder={images[selectedIndex].blurDataURL ? 'blur' : 'empty'}
          blurDataURL={images[selectedIndex].blurDataURL}
          preload={selectedIndex === 0}  // Preload first image
        />
      </div>

      {/* Thumbnails */}
      <div className="flex gap-2 overflow-x-auto">
        {images.map((image, index) => (
          <button
            key={image.src}
            onClick={() => setSelectedIndex(index)}
            className={`relative h-20 w-20 flex-shrink-0 overflow-hidden rounded ${
              index === selectedIndex ? 'ring-2 ring-blue-500' : ''
            }`}
          >
            <Image
              src={image.src}
              alt={`Thumbnail ${index + 1}`}
              fill
              sizes="80px"
              style={{ objectFit: 'cover' }}
            />
          </button>
        ))}
      </div>
    </div>
  )
}

Pattern 3: Lazy-Loaded Image Grid

// components/ImageGrid.tsx
import Image from 'next/image'

interface ImageGridProps {
  images: Array<{
    id: string
    src: string
    alt: string
    width: number
    height: number
    blurDataURL?: string
  }>
}

export function ImageGrid({ images }: ImageGridProps) {
  return (
    <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
      {images.map((image, index) => (
        <div
          key={image.id}
          className="relative aspect-square overflow-hidden rounded-lg"
        >
          <Image
            src={image.src}
            alt={image.alt}
            fill
            sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
            style={{ objectFit: 'cover' }}
            placeholder={image.blurDataURL ? 'blur' : 'empty'}
            blurDataURL={image.blurDataURL}
            // First 4 images load eagerly (above fold)
            loading={index < 4 ? 'eager' : 'lazy'}
          />
        </div>
      ))}
    </div>
  )
}

Pattern 4: Art Direction (Different Images per Breakpoint)

// components/ResponsiveBanner.tsx
import { getImageProps } from 'next/image'

interface ResponsiveBannerProps {
  desktopSrc: string
  mobileSrc: string
  alt: string
}

export function ResponsiveBanner({
  desktopSrc,
  mobileSrc,
  alt,
}: ResponsiveBannerProps) {
  const common = { alt, sizes: '100vw' }

  const {
    props: { srcSet: desktopSrcSet },
  } = getImageProps({
    ...common,
    src: desktopSrc,
    width: 1920,
    height: 600,
    quality: 85,
  })

  const {
    props: { srcSet: mobileSrcSet, ...rest },
  } = getImageProps({
    ...common,
    src: mobileSrc,
    width: 750,
    height: 1000,
    quality: 80,
  })

  return (
    <picture>
      <source media="(min-width: 768px)" srcSet={desktopSrcSet} />
      <source media="(max-width: 767px)" srcSet={mobileSrcSet} />
      <img
        {...rest}
        style={{ width: '100%', height: 'auto' }}
        loading="eager"
      />
    </picture>
  )
}

Pattern 5: Theme-Aware Images

// components/ThemeImage.tsx
import Image, { type ImageProps } from 'next/image'
import styles from './ThemeImage.module.css'

type ThemeImageProps = Omit<ImageProps, 'src'> & {
  srcLight: string
  srcDark: string
}

export function ThemeImage({ srcLight, srcDark, alt, ...props }: ThemeImageProps) {
  return (
    <>
      <Image
        {...props}
        src={srcLight}
        alt={alt}
        className={styles.light}
      />
      <Image
        {...props}
        src={srcDark}
        alt={alt}
        className={styles.dark}
      />
    </>
  )
}
/* ThemeImage.module.css */
.dark {
  display: none;
}

@media (prefers-color-scheme: dark) {
  .light {
    display: none;
  }
  .dark {
    display: block;
  }
}

Pattern 6: Background Image with Optimization

// components/BackgroundSection.tsx
import { getImageProps } from 'next/image'

interface BackgroundSectionProps {
  imageSrc: string
  children: React.ReactNode
}

function getBackgroundImage(srcSet: string) {
  const imageSet = srcSet
    .split(', ')
    .map((str) => {
      const [url, dpi] = str.split(' ')
      return `url("${url}") ${dpi}`
    })
    .join(', ')
  return `image-set(${imageSet})`
}

export function BackgroundSection({
  imageSrc,
  children,
}: BackgroundSectionProps) {
  const {
    props: { srcSet },
  } = getImageProps({
    alt: '',
    src: imageSrc,
    width: 1920,
    height: 1080,
    quality: 80,
  })

  const backgroundImage = getBackgroundImage(srcSet!)

  return (
    <section
      style={{
        backgroundImage,
        backgroundSize: 'cover',
        backgroundPosition: 'center',
      }}
      className="relative min-h-[500px]"
    >
      <div className="absolute inset-0 bg-black/50" />
      <div className="relative z-10">{children}</div>
    </section>
  )
}

Performance Optimization

LCP Optimization Checklist

┌─────────────────────────────────────────────────────────────────────────────┐
│                     LCP IMAGE OPTIMIZATION CHECKLIST                        │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ✓ Use preload={true} for hero images                                     │
│  ✓ Use loading="eager" for above-the-fold images                          │
│  ✓ Avoid loading="lazy" on LCP images                                     │
│  ✓ Provide accurate sizes prop for responsive images                      │
│  ✓ Use static imports for auto width/height                               │
│  ✓ Enable AVIF for best compression: formats: ['image/avif', 'image/webp']│
│  ✓ Preconnect to external image domains:                                  │
│      <link rel="preconnect" href="https://cdn.example.com" />             │
│  ✓ Avoid placeholder="blur" on LCP images (adds decode time)              │
│  ✓ Use appropriate quality (75-85 for photos, higher for text)            │
│                                                                             │
│  MEASURING LCP:                                                             │
│  • Chrome DevTools → Performance → Web Vitals                              │
│  • Lighthouse → Performance audit                                           │
│  • Real User Monitoring (RUM) via web-vitals library                       │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

CLS Prevention

// Always provide dimensions to prevent layout shift

// Option 1: Static import (auto dimensions)
import heroImage from './hero.jpg'
<Image src={heroImage} alt="Hero" />

// Option 2: Explicit dimensions
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
/>

// Option 3: Fill with sized container
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
  <Image src="/photo.jpg" alt="Photo" fill />
</div>

// Option 4: CSS aspect ratio
<div className="relative aspect-video">
  <Image src="/video-thumb.jpg" alt="Video" fill />
</div>

Common Pitfalls and Solutions

Pitfall 1: Missing remotePatterns

// ❌ ERROR: Invalid src prop on next/image
<Image src="https://external-site.com/image.jpg" ... />

// ✅ Configure remotePatterns
// next.config.ts
{
  images: {
    remotePatterns: [
      { hostname: 'external-site.com' }
    ]
  }
}

Pitfall 2: Missing Width/Height for Remote Images

// ❌ ERROR: Missing width or height
<Image src="https://cdn.example.com/photo.jpg" alt="Photo" />

// ✅ Provide dimensions
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
/>

// ✅ Or use fill
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
  <Image
    src="https://cdn.example.com/photo.jpg"
    alt="Photo"
    fill
  />
</div>

Pitfall 3: Fill Without Positioned Parent

// ❌ Image won't display correctly
<div>
  <Image src="/photo.jpg" alt="Photo" fill />
</div>

// ✅ Parent must have position
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
  <Image src="/photo.jpg" alt="Photo" fill />
</div>

Pitfall 4: Missing sizes for Responsive Images

// ❌ Browser downloads 100vw image even on small displays
<div className="w-1/3">
  <Image src="/photo.jpg" alt="Photo" fill />
</div>

// ✅ Provide sizes to optimize srcset selection
<div className="w-full md:w-1/2 lg:w-1/3">
  <Image
    src="/photo.jpg"
    alt="Photo"
    fill
    sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
  />
</div>

Pitfall 5: Quality Not in Allowlist

// ❌ ERROR: Quality 90 not in qualities array
<Image src="/photo.jpg" alt="Photo" width={800} height={600} quality={90} />

// ✅ Configure allowed qualities
// next.config.ts
{
  images: {
    qualities: [25, 50, 75, 90, 100]  // Add 90 to allowlist
  }
}

Key Takeaways

  1. Static Imports for Local Images: Auto width/height/blur - best DX and performance
  2. Always Provide sizes for fill Images: Critical for responsive srcset selection
  3. preload for LCP Images: Use for hero images above the fold
  4. Configure remotePatterns Strictly: Security first - be specific with patterns
  5. Enable AVIF: 20% smaller than WebP with formats: ['image/avif', 'image/webp']
  6. quality 75-85 for Photos: Balance between file size and visual quality
  7. Use fill for Unknown Dimensions: Combined with objectFit for flexible layouts
  8. Avoid Lazy Loading LCP: Use loading="eager" for above-the-fold images
  9. getImageProps for Advanced Use: Art direction, background images, custom elements
  10. Custom Loaders for CDNs: Cloudinary, Imgix, etc. - use loader prop or loaderFile

References

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.