Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 042 / 100

next/image, properly: a practical React & Next.js guide

Responsive, lazy and optimized images.

Expona Admin8 min read

Images are everywhere in modern web applications, but they're often treated as an afterthought. I've seen teams ship React and Next.js apps with unoptimized images that tank performance, drain user data, and cause layout shifts that feel janky. Getting next/image, properly requires understanding how the Image component's props interact with responsive design, lazy loading, and Core Web Vitals.

Why next/image matters

Before diving into implementation details, let's acknowledge the problem. HTML's native <img> tag doesn't optimize images by default. Browsers download the full resolution you serve, regardless of screen size or connection speed. This leads to bloated bundle sizes, slow page loads, and poor Core Web Vitals scores—all directly impacting SEO and user experience.

Next.js's <Image> component (from next/image) solves this by automatically optimizing images at build time and serving them in modern formats like WebP. But the component only works well when you configure it correctly. Miss a few key settings, and you'll get minimal benefits or even introduce new problems.

Always set sizes — Without sizes, the browser may download a much larger image than needed

The sizes prop is the most misunderstood setting in next/image. When you omit it, Next.js defaults to 100vw, meaning the browser assumes your image will stretch to full viewport width. On mobile, this triggers download of a desktop-sized image, wasting bandwidth and slowing the page.

The sizes attribute tells the browser which image width to fetch based on viewport breakpoints. It uses media queries to describe how an image will render at different screen sizes, letting the browser download only what it needs.

tsx
// ❌ Bad: No sizes prop. Browser downloads large image on mobile.
<Image
  src="/product.jpg"
  alt="Product photo"
  fill
/>

// ✅ Good: Explicit sizes for responsive rendering
<Image
  src="/product.jpg"
  alt="Product photo"
  fill
  sizes="(max-width: 768px) 50vw, 25vw"
/>

In this example, on screens ≤768px, the image renders at 50% of viewport width. On larger screens, it's 25vw. The browser then selects the smallest optimized image that covers both scenarios, reducing file size significantly.

I worked on an e-commerce platform where product grids showed 4 items per row on desktop and 2 on mobile. Without proper sizes, mobile devices were downloading the full 1200px desktop image instead of a 300px version. Adding sizes="(max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw" cut mobile image bytes by 60% immediately.

The syntax follows a pattern: (media-query) width, (media-query) width, ... default-width. Always provide a default at the end with no media query. Common units are vw (viewport width), px, or em.

tsx
// Hero image: full width on mobile, constrained on desktop
<Image
  src="/hero.jpg"
  alt="Hero banner"
  fill
  sizes="100vw"
  style={{ objectFit: "cover" }}
/>

// Sidebar widget: always 200px
<Image
  src="/widget.jpg"
  alt="Sidebar widget"
  width={200}
  height={200}
  sizes="200px"
/>

// Card in a 3-column grid
<Image
  src="/card.jpg"
  alt="Card"
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

Priority for LCP — Mark the hero image with priority so it loads first

Largest Contentful Paint (LCP) is one of Google's Core Web Vitals, and it measures when the largest visible element appears on screen. For most pages, that's the hero image. If it loads slowly, your LCP score suffers, hurting rankings and user experience.

By default, images are lazy-loaded, meaning they only start downloading when they're about to enter the viewport. For above-the-fold images, this delay is wasted. The priority prop tells Next.js to preload the image as early as possible, removing unnecessary latency.

tsx
// ❌ Bad: Hero image lazy-loads like a footer image
<Image
  src="/hero.jpg"
  alt="Hero banner"
  fill
  sizes="100vw"
/>

// ✅ Good: Hero image preloads immediately
<Image
  src="/hero.jpg"
  alt="Hero banner"
  fill
  sizes="100vw"
  priority
/>

When priority={true}, Next.js adds a <link rel="preload"> tag in the document head, and the browser fetches the image before rendering the page. For images outside the viewport (like a carousel's second slide), use lazy loading instead.

On a travel booking platform, I applied priority to the destination hero image but left carousel thumbnails with lazy loading. LCP improved by ~400ms, and we didn't unnecessarily download off-screen images.

Use priority sparingly. Preloading too many images defeats the purpose and bloats the initial document. Typically, only the single most important image per page needs it.

tsx
// In a product detail page
export default function ProductPage({ product }) {
  return (
    <>
      {/* Hero image: priority for LCP */}
      <Image
        src={product.mainImage}
        alt={product.name}
        fill
        priority
        sizes="100vw"
      />
      
      {/* Thumbnails: lazy load */}
      <div>
        {product.images.map((img, idx) => (
          <Image
            key={idx}
            src={img}
            alt={`${product.name} view ${idx + 1}`}
            width={100}
            height={100}
          />
        ))}
      </div>
    </>
  );
}

Width and height — Prevent layout shift by reserving space

Layout shift happens when elements move on the page after content loads. If an image has no reserved space, the browser flows other content into that area, then reflows when the image arrives. This is annoying for users and harms Cumulative Layout Shift (CLS), another Core Web Vital.

Setting width and height (or using fill with a container of known aspect ratio) tells the browser to reserve space for the image before it downloads, preventing reflow.

tsx
// ❌ Bad: No dimensions. Text shifts when image loads.
<Image src="/photo.jpg" alt="Photo" />

// ✅ Good: Explicit dimensions reserve space
<Image
  src="/photo.jpg"
  alt="Photo"
  width={400}
  height={300}
/>

// ✅ Also good: fill with aspect-ratio container
<div style={{ position: "relative", width: "400px", aspectRatio: "4/3" }}>
  <Image
    src="/photo.jpg"
    alt="Photo"
    fill
  />
</div>

When you provide width and height, Next.js calculates the aspect ratio and generates a CSS rule that reserves space. The image loads into that reserved area without pushing content around.

I've debugged CLS issues on SaaS platforms where profile images in tables had no dimensions. Users clicked a button, the image loaded, and the entire row shifted. Adding width and height fixed it instantly.

If your image dimensions vary (e.g., a grid where items have different aspect ratios), use a container with a known aspect ratio and the fill prop. This is cleaner than hardcoding dimensions for every variant.

tsx
// Variable-sized images in a masonry grid
function ImageCard({ image }) {
  const aspectRatio = image.width / image.height;
  
  return (
    <div
      style={{
        position: "relative",
        width: "100%",
        aspectRatio: `${aspectRatio}`
      }}
    >
      <Image
        src={image.url}
        alt={image.alt}
        fill
        sizes="(max-width: 640px) 100vw, 50vw"
      />
    </div>
  );
}

Remote patterns — Allow-list external image hosts in next.config

Next.js optimizes images locally at build time if they're in your public folder, but external images (from CDNs, APIs, or third-party services) are optimized on-demand. To serve remote images, you must allow-list their hosts in next.config.js.

Without this configuration, Next.js blocks external image hosts by default, returning an error. This is a security feature—it prevents your server from being abused as a proxy for arbitrary URLs.

tsx
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "api.example.com",
        pathname: "/images/**",
      },
      {
        protocol: "https",
        hostname: "*.cloudinary.com",
      },
      {
        protocol: "https",
        hostname: "images.unsplash.com",
      },
    ],
  },
};

module.exports = nextConfig;

Each object in remotePatterns specifies a host and optional path and protocol. You can use wildcards in hostname (e.g., *.cloudinary.com) to match subdomains. The pathname field lets you restrict to specific paths if needed.

On a CRM platform, we pulled user avatars from Auth0 and product images from Cloudinary. I added both hosts to remotePatterns, and Next.js optimized them automatically on first request and cached the result.

Be specific when possible. Instead of allowing all hosts with a wildcard, list only the services you actually use. This prevents accidentally serving images from untrusted sources.

tsx
// Good: Allow specific CDN and API hosts
images: {
  remotePatterns: [
    { protocol: "https", hostname: "cdn.myapp.com" },
    { protocol: "https", hostname: "user-uploads.myapp.com" },
  ],
}

// Questionable: Allow all subdomains of a domain
images: {
  remotePatterns: [
    { protocol: "https", hostname: "**.example.com" },
  ],
}

// Bad: Allow everything (security risk)
images: {
  remotePatterns: [
    { protocol: "https", hostname: "*" },
  ],
}

Common mistakes

  • Omitting sizes with fill: When using fill, always include sizes. Without it, Next.js assumes 100vw, and the browser downloads oversized images on mobile.
  • Using priority on multiple images: Only the single most important image needs priority. Using it on 5 images defeats preloading optimization and slows page load.
  • No width/height with fixed dimensions: If you know the image dimensions, always set width and height to prevent layout shift. This is especially important for images in lists or grids.
  • Forgetting remotePatterns: Forgetting to allow-list a CDN host will cause silent failures (images won't load in production) or cryptic errors. Check next.config.js early when debugging missing external images.
  • Bloated sizes strings: Keep sizes simple and aligned with your CSS breakpoints. Over-complicated sizes don't improve optimization and are hard to maintain.
  • Using fill without container positioning: When using fill, the parent must have position: relative (or absolute, fixed). Otherwise, the image won't render.

Key takeaways

  • Always set sizes with fill to let the browser download right-sized images for each viewport. Without it, mobile users download desktop images unnecessarily.
  • Use priority on exactly one hero image per page to improve LCP. Don't mark multiple images as priority.
  • Set width and height (or use fill with aspect-ratio container) to reserve space and prevent Cumulative Layout Shift.
  • Allow-list external image hosts in next.config.js under remotePatterns. Be specific—restrict to the services you actually use.
  • Combine these settings for best results. A well-optimized image uses fill, sizes, proper dimensions, and priority (if above-the-fold), plus proper remote pattern configuration.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles