MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js Image Optimization: next/image Component

Optimize images in Next.js: automatic resizing, WebP conversion, lazy loading, and placeholders.

Next.jsImage OptimizationPerformanceFrontend

By MinhVo

Introduction

The next/image component is Next.js's built-in solution for one of the web's most persistent performance problems: unoptimized images. It replaces the standard HTML <img> tag with an intelligent component that automatically serves images in modern formats (WebP, AVIF), resizes them for each device, lazy-loads below-the-fold images, and prevents layout shifts — all without manual configuration.

Before next/image, optimizing images required setting up an image CDN, manually writing srcset attributes, implementing lazy loading with Intersection Observer, and generating multiple image sizes at build time. Now, a single component declaration handles all of this automatically, resulting in measurable improvements to Core Web Vitals and user experience.

This guide covers everything from basic usage to advanced patterns, including custom loaders, blur placeholders, and integration with third-party image services.

Image optimization pipeline

Understanding next/image: Core Concepts

Basic Usage

The simplest usage requires src, alt, and either width/height or fill:

import Image from "next/image";
 
// Fixed dimensions
<Image
  src="/profile.jpg"
  alt="Profile photo"
  width={200}
  height={200}
/>
 
// Fill parent container
<div className="relative w-full h-64">
  <Image
    src="/banner.jpg"
    alt="Banner"
    fill
    className="object-cover"
  />
</div>

What Happens Under the Hood

When you render <Image src="/photo.jpg" width={800} height={600} />, Next.js:

  1. Generates a <picture> element with multiple <source> entries for AVIF and WebP formats
  2. Creates srcset with multiple widths (640, 750, 828, 1080, 1200, 1920)
  3. Adds sizes="100vw" as a default (you should override this)
  4. Sets loading="lazy" by default (images load as they approach the viewport)
  5. Adds decoding="async" for non-blocking decode
  6. Reserves space with explicit width and height to prevent layout shift
  7. Creates an optimization endpoint (/_next/image?url=...&w=...&q=...) that serves optimized images

Props Reference

PropTypeDefaultDescription
srcstringRequiredImage source URL or static import
altstringRequiredAlternative text for accessibility
widthnumber—Intrinsic width in pixels
heightnumber—Intrinsic height in pixels
fillbooleanfalseFill parent container (needs position: relative)
sizesstring"100vw"Responsive sizes for srcset
qualitynumber75Output quality (1-100)
prioritybooleanfalseDisable lazy loading, add fetchpriority="high"
placeholderstring"empty""empty" or "blur"
blurDataURLstring—Base64 blur placeholder (auto-generated for static imports)
loadingstring"lazy""lazy" or "eager"
loaderfunctiondefaultCustom image loader function

Responsive image generation

Architecture and Design Patterns

Pattern 1: Responsive Image with Proper Sizes

The most impactful optimization you can make is setting the sizes attribute correctly:

// Blog article image — takes full width on mobile, max 720px on desktop
<Image
  src={post.coverImage}
  alt={post.title}
  width={1200}
  height={630}
  sizes="(max-width: 768px) 100vw, 720px"
/>
 
// Product card in a 4-column grid
<Image
  src={product.image}
  alt={product.name}
  width={400}
  height={400}
  sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
/>
 
// Hero image that spans full viewport
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1920}
  height={1080}
  sizes="100vw"
  priority
/>

Without sizes, the browser defaults to 100vw and may download a 1920px image for a 400px card.

Pattern 2: Blur Placeholder for Progressive Loading

Static imports automatically generate blur data:

import heroImage from "@/public/hero.jpg";
 
<Image
  src={heroImage}
  alt="Hero"
  placeholder="blur"
  priority
/>

For external URLs, generate the blur data server-side:

import { getPlaiceholder } from "plaiceholder";
 
export async function BlurredImage({ src, alt }: { src: string; alt: string }) {
  const buffer = await fetch(src).then(r => r.arrayBuffer());
  const { base64 } = await getPlaiceholder(Buffer.from(buffer));
 
  return (
    <Image
      src={src}
      alt={alt}
      fill
      placeholder="blur"
      blurDataURL={base64}
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}
"use client";
 
import Image from "next/image";
import { useState } from "react";
 
export function ImageGallery({ images }: { images: string[] }) {
  const [active, setActive] = useState(0);
  const [zoomed, setZoomed] = useState(false);
 
  return (
    <div className="space-y-4">
      <div
        className={`relative cursor-zoom-in ${zoomed ? "cursor-zoom-out" : ""}`}
        onClick={() => setZoomed(!zoomed)}
      >
        <div className={`relative ${zoomed ? "h-[80vh]" : "aspect-square"} overflow-hidden rounded-lg`}>
          <Image
            src={images[active]}
            alt={`Image ${active + 1}`}
            fill
            sizes="(max-width: 768px) 100vw, 800px"
            className={`object-contain transition-transform duration-300 ${
              zoomed ? "scale-150" : "scale-100"
            }`}
            priority={active === 0}
          />
        </div>
      </div>
 
      <div className="flex gap-2">
        {images.map((src, i) => (
          <button
            key={i}
            onClick={() => { setActive(i); setZoomed(false); }}
            className={`relative w-16 h-16 rounded border-2 overflow-hidden ${
              i === active ? "border-blue-500" : "border-gray-200"
            }`}
          >
            <Image src={src} alt={`Thumbnail ${i + 1}`} fill sizes="64px" className="object-cover" />
          </button>
        ))}
      </div>
    </div>
  );
}

Step-by-Step Implementation

Step 1: Configure Remote Image Domains

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.unsplash.com",
      },
      {
        protocol: "https",
        hostname: "cdn.your-app.com",
        pathname: "/uploads/**",
      },
    ],
    // Enable modern formats
    formats: ["image/avif", "image/webp"],
    // Device breakpoints for srcset
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    // Icon/thumbnail breakpoints
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};
 
export default nextConfig;

Step 2: Create a Reusable Image Component

// components/ui/OptimizedImage.tsx
import Image, { type ImageProps } from "next/image";
 
interface OptimizedImageProps extends Omit<ImageProps, "sizes"> {
  variant?: "hero" | "card" | "thumbnail" | "full";
}
 
const sizeMap = {
  hero: "100vw",
  card: "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw",
  thumbnail: "(max-width: 640px) 50vw, 150px",
  full: "(max-width: 768px) 100vw, 720px",
};
 
export function OptimizedImage({ variant = "full", ...props }: OptimizedImageProps) {
  return (
    <Image
      {...props}
      sizes={sizeMap[variant]}
      quality={variant === "hero" ? 85 : 75}
    />
  );
}

Step 3: Implement Background Images

export function BackgroundImage({ src, alt, children }: {
  src: string;
  alt: string;
  children: React.ReactNode;
}) {
  return (
    <div className="relative min-h-screen">
      <Image
        src={src}
        alt={alt}
        fill
        sizes="100vw"
        quality={80}
        className="object-cover"
        priority
      />
      <div className="absolute inset-0 bg-black/50" />
      <div className="relative z-10">{children}</div>
    </div>
  );
}

Step 4: Handle Errors Gracefully

"use client";
 
import Image from "next/image";
import { useState } from "react";
 
export function SafeImage({ src, alt, fallback = "/placeholder.jpg", ...props }: {
  src: string;
  alt: string;
  fallback?: string;
} & Omit<React.ComponentProps<typeof Image>, "src" | "alt">) {
  const [imgSrc, setImgSrc] = useState(src);
 
  return (
    <Image
      {...props}
      src={imgSrc}
      alt={alt}
      onError={() => setImgSrc(fallback)}
    />
  );
}

Implementation patterns

Real-World Use Cases

Use Case 1: Social Media Feed

A social media feed displays user-uploaded images of varying dimensions. Using fill with object-cover ensures all images display at consistent sizes without distortion. Lazy loading prevents the browser from downloading all images at once — only images within 250px of the viewport are loaded. The result: 85% reduction in initial page weight for feeds with 50+ images.

Use Case 2: Real Estate Listings

A real estate site uses hero images with priority for the first listing, blur placeholders for all listing photos, and responsive sizes that serve 400px-wide images on mobile (card view) and 800px images on desktop (detail view). Combined with AVIF format, the site loads 3x faster than the previous implementation using raw <img> tags.

Use Case 3: Documentation Site

A technical documentation site uses next/image for diagrams and screenshots. Each image has a descriptive alt text, appropriate sizes for the content column width, and a shimmer placeholder that matches the site's design system. The site achieves a perfect 100 Lighthouse accessibility score while maintaining excellent performance.

Best Practices for Production

  1. Always set sizes: The default 100vw causes the browser to download images larger than needed. Match sizes to your CSS layout.

  2. Use priority for LCP images: The first visible image (hero, product photo, article thumbnail) should have priority to optimize LCP.

  3. Set width and height: These prevent layout shifts by reserving space before the image loads. If dimensions are unknown, use fill with a sized parent.

  4. Enable AVIF: Add formats: ["image/avif", "image/webp"] to next.config.ts. AVIF is 30% smaller than WebP.

  5. Use blur placeholders for hero images: placeholder="blur" with blurDataURL gives users a preview while the full image loads.

  6. Don't use priority on every image: Only above-the-fold images should have priority. Using it on all images defeats lazy loading.

  7. Use static imports when possible: import img from "@/public/photo.jpg" auto-generates blur data and enables build-time optimization.

  8. Set quality appropriately: Use 85-90 for hero/detail images, 60-75 for thumbnails and cards.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing sizesOversized images downloaded on mobileSet sizes matching your CSS layout
No width/height on non-fill imagesLayout shift (CLS)Always set dimensions or use fill
External domain not configured403 errorAdd to remotePatterns in next.config.ts
Using priority on all imagesNo lazy loading, slow initial loadOnly use on above-the-fold images
Forgetting object-cover with fillDistorted imagesAlways add className="object-cover" or object-contain
Large source imagesSlow optimizationPre-compress to reasonable sizes (max 2-3MB)

Performance Optimization

// Complete optimized image setup
import Image from "next/image";
import heroImg from "@/public/hero.jpg";
 
// Hero: priority, blur, high quality
<Image
  src={heroImg}
  alt="Hero banner"
  priority
  placeholder="blur"
  quality={90}
  sizes="100vw"
  className="object-cover"
/>
 
// Product card: lazy, standard quality, responsive
<Image
  src={product.image}
  alt={product.name}
  width={400}
  height={400}
  quality={75}
  sizes="(max-width: 640px) 50vw, 25vw"
  className="object-cover rounded-lg"
/>
 
// Thumbnail: lazy, lower quality, small size
<Image
  src={thumbnail}
  alt="Thumbnail"
  width={80}
  height={80}
  quality={60}
  sizes="80px"
  className="rounded-full"
/>

Comparison with Alternatives

Featurenext/imageCloudinaryImgixManual <img>
Format negotiationAutomaticVia URL paramsVia URL paramsManual
Responsive srcsetAutomaticManualManualManual
Lazy loadingBuilt-inNoneNoneIntersection Observer
Blur placeholderBuilt-inVia URL paramsVia URL paramsManual
Layout shift preventionBuilt-inNoneNoneManual
Setup effortZeroMediumMediumHigh

Image Preloading and Resource Hints

Beyond priority, you can preload critical images using <link rel="preload"> in your layout for images that next/image can't detect (like CSS background images or images loaded via JavaScript):

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          as="image"
          href="/hero.jpg"
          imagesrcset="/hero-640.avif 640w, /hero-1080.avif 1080w, /hero-1920.avif 1920w"
          imagesizes="100vw"
          type="image/avif"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

This ensures the browser starts downloading the hero image before parsing JavaScript, giving you the fastest possible LCP. Combine with fetchpriority="high" on the image element for maximum effect.

For font files associated with image-heavy pages (like design portfolios), preload them too to avoid the flash of unstyled text while images load.

Advanced Patterns

Custom Loader for AWS S3 + CloudFront

// lib/s3-loader.ts
export function s3Loader({ src, width, quality }: {
  src: string;
  width: number;
  quality?: number;
}) {
  return `https://cdn.example.com/cdn-cgi/image/width=${width},quality=${quality || 75}${src}`;
}
 
// next.config.ts
const nextConfig = {
  images: {
    loader: "custom",
    loaderFile: "./lib/s3-loader.ts",
  },
};

SVG Component Integration

// For SVGs, use regular <img> or import as React components
import Logo from "@/public/logo.svg";
 
export function SiteLogo() {
  return (
    <Image
      src={Logo}
      alt="Company Logo"
      width={120}
      height={40}
      priority
    />
  );
}

Image CDN Integration

For applications serving millions of images, offloading optimization to a dedicated CDN provides better global performance and reduces load on your Next.js server. Cloudinary, Imgix, and Bunny.net are popular choices that integrate with next/image via custom loaders.

Cloudinary Integration

// lib/cloudinary-loader.ts
export function cloudinaryLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  const params = [
    `f_auto`,
    `c_limit`,
    `w_${width}`,
    `q_${quality || "auto"}`,
  ];
  return `https://res.cloudinary.com/your-cloud/image/upload/${params.join(",")}${src}`;
}
 
// Usage in next.config.ts
const nextConfig = {
  images: {
    loader: "custom",
    loaderFile: "./lib/cloudinary-loader.ts",
    remotePatterns: [
      {
        protocol: "https",
        hostname: "res.cloudinary.com",
      },
    ],
  },
};

Cloudinary's f_auto parameter automatically serves AVIF to supported browsers, WebP to Chrome/Firefox, and JPEG as fallback — matching what next/image does natively but with global edge caching.

Bunny.net CDN with Next.js

// lib/bunny-loader.ts
export function bunnyLoader({ src, width, quality }: {
  src: string;
  width: number;
  quality?: number;
}) {
  return `https://your-zone.b-cdn.net${src}?width=${width}&quality=${quality || 75}&format=auto`;
}

Bunny.net offers per-pixel pricing that's significantly cheaper than Cloudinary for high-traffic sites. Their edge storage puts images within 30ms of 95% of global users.

Dynamic OG Image Generation

For social sharing, generate Open Graph images dynamically using @vercel/og:

// app/api/og/route.tsx
import { ImageResponse } from "next/og";
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") || "Default Title";
 
  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          height: "100%",
          width: "100%",
          alignItems: "center",
          justifyContent: "center",
          background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        }}
      >
        <h1 style={{ fontSize: 64, color: "white", textAlign: "center" }}>
          {title}
        </h1>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Then reference in your layout: <meta property="og:image" content="https://yourdomain.com/api/og?title=My+Post" />. This eliminates the need to pre-generate and store OG images for every page.

Measuring Image Performance

Core Web Vitals Impact

Images directly affect all three Core Web Vitals. The largest contentful paint (LCP) element is an image on over 70% of web pages. Cumulative layout shift (CLS) is often caused by images without explicit dimensions. Even interaction to next paint (INP) can be affected by heavy image decoding that blocks the main thread.

Use Lighthouse CI in your build pipeline to catch image regressions before they reach production:

// lighthouse-ci.config.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
        "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
        "uses-responsive-images": ["warn"],
        "uses-optimized-images": ["warn"],
        "modern-image-formats": ["warn"],
      },
    },
  },
};

Debugging Image Loading Issues

When images aren't performing as expected, the browser DevTools Network tab reveals the full picture. Filter by "Img" to see all image requests, their sizes, formats, and loading waterfall. Look for these common issues:

  • Missing sizes attribute: The browser downloads a larger image than displayed. Check the "Initiator" column — if it shows sizes="(max-width: ...)..." is missing, the browser defaults to 100vw.
  • Wrong priority usage: LCP images without priority load late. Compare the image's start time against DOMContentLoaded.
  • Format fallback: If you see JPEG instead of AVIF/WebP, check your next.config.ts format configuration and verify the image optimizer is running (not disabled via unoptimized: true).

The next/image component logs warnings in development mode for common mistakes like missing alt text, incorrect width/height on static imports, and oversized external images. These warnings are suppressed in production, so always check your development console.

Image CDN Analytics

If you use an external image CDN (Cloudinary, Imgix, Bunny.net), their analytics dashboards provide insights that Next.js alone cannot:

  • Format distribution: What percentage of requests serve AVIF vs WebP vs JPEG? Aim for 80%+ modern formats.
  • Cache hit rate: Should be above 95% for production traffic. Low rates indicate cache configuration issues.
  • Bandwidth savings: Compare original image sizes vs delivered sizes. The delta is your optimization savings.
  • Error rates: 404s on image requests often indicate broken image references or expired signed URLs.
// Example: Track image performance in your app
function trackImageMetrics() {
  if (typeof window === "undefined") return;
 
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.element?.tagName === "IMG") {
        const img = entry.element as HTMLImageElement;
        console.log({
          src: img.src,
          naturalWidth: img.naturalWidth,
          naturalHeight: img.naturalHeight,
          displayWidth: img.clientWidth,
          displayHeight: img.clientHeight,
          loadTime: entry.startTime + entry.duration,
          format: img.currentSrc.match(/\.(\w+)$/)?.[1] || "unknown",
        });
      }
    }
  });
 
  observer.observe({ type: "largest-contentful-paint", buffered: true });
}

Testing Strategies

import { render, screen } from "@testing-library/react";
import Image from "next/image";
 
describe("next/image Component", () => {
  it("renders with correct attributes", () => {
    render(<Image src="/test.jpg" alt="Test" width={800} height={600} />);
    const img = screen.getByAltText("Test");
    expect(img).toHaveAttribute("width", "800");
    expect(img).toHaveAttribute("height", "600");
  });
 
  it("applies lazy loading by default", () => {
    render(<Image src="/test.jpg" alt="Test" width={800} height={600} />);
    const img = screen.getByAltText("Test");
    expect(img).toHaveAttribute("loading", "lazy");
  });
 
  it("applies eager loading with priority", () => {
    render(<Image src="/hero.jpg" alt="Hero" width={1920} height={1080} priority />);
    const img = screen.getByAltText("Hero");
    expect(img).toHaveAttribute("loading", "eager");
  });
 
  it("generates srcset with multiple widths", () => {
    render(<Image src="/photo.jpg" alt="Photo" width={1200} height={600} />);
    const img = screen.getByAltText("Photo");
    const srcset = img.getAttribute("srcset");
    expect(srcset).toBeTruthy();
    expect(srcset).toContain("640w");
    expect(srcset).toContain("1200w");
  });
});

Future Outlook

The next/image component continues to evolve with each Next.js release. Recent improvements include better support for SVG images, improved caching strategies, and integration with React Suspense for streaming image loading. The React team's exploration of <Image> as a first-class React primitive may eventually provide native browser-level image optimization that builds on the patterns Next.js pioneered.

As AVIF adoption grows and new formats emerge, Next.js's automatic format negotiation ensures your images will always be served in the most efficient format available — without any code changes.

Conclusion

The next/image component is the single most impactful performance optimization available in Next.js. It handles format negotiation, responsive sizing, lazy loading, and layout shift prevention automatically.

Key takeaways:

  1. Replace all <img> with next/image for automatic optimization, responsive sizing, and lazy loading.
  2. Set sizes correctly — this is the most common mistake and the easiest performance win.
  3. Use priority on LCP images — hero banners, product photos, and article thumbnails above the fold.
  4. Enable AVIF format for 30% smaller images compared to WebP.
  5. Use blur placeholders for a polished loading experience.

Start by auditing your largest images and applying these optimizations. The performance improvements are immediate and measurable.

For more details, see the Next.js Image documentation.