50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 18, 2026·7 min read

Next.js Image Component: Performance Gains You're Probably Missing

Most devs use next/image just to silence the linter. Here's what it actually does and how to squeeze every bit of performance from it.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Next.js Image Component: Performance Gains You're Probably Missing

We've reviewed a lot of Next.js codebases — our own included — and there's a pattern we keep seeing. Someone installed next/image, swapped out the `<img>` tags to make the console stop yelling at them, set a width and height, and called it done. Job complete. Ship it.

That's leaving a lot of performance on the table. The Image component is genuinely one of the most powerful tools Next.js ships, and most people are using maybe 30% of what it can do. Let's fix that.

What next/image Actually Does Under the Hood

Before we get into the good stuff, it helps to understand what's actually happening when you use the component. Next.js doesn't just passively serve your images — it runs them through a server-side optimization pipeline. When a browser requests an image, Next.js intercepts it via the `/_next/image` route, resizes it to the exact dimensions needed, converts it to a modern format (WebP or AVIF depending on what the browser supports), compresses it, and caches the result. All of this happens on-demand the first time an image is requested, and then it's cached for subsequent requests.

That last part is important: the optimization is lazy. The first visitor to a page might get a slightly slower experience while the image is being processed. Every visitor after that gets the cached, optimized version. For most use cases this is fine. For landing pages where first impressions matter, it's worth being aware of.

Priority Loading: The Most Underused Feature

Here's the thing that will immediately improve your Largest Contentful Paint score. Any image that's visible in the viewport without scrolling — your hero image, your logo, the main product shot — should have the `priority` prop. Without it, next/image lazy-loads everything, which means your hero image is being deferred while the browser figures out what actually needs loading first.

// ❌ This delays your LCP — hero image loads lazily
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
/>

// ✅ This tells the browser: load this NOW, it's above the fold
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority
/>

// priority does two things:
// 1. Adds <link rel="preload"> to the <head>
// 2. Disables lazy loading for this image

Rule of thumb: the first image a user sees on any page gets `priority`. Everything else stays lazy. We used to forget this constantly — until we ran our own pages through PageSpeed Insights and watched it scream at us about LCP. Lesson learned.

Sizes: Teaching the Browser What Size Image It Actually Needs

This is the one that trips people up the most. When you use `fill` layout or when your image changes size across breakpoints, you need to tell Next.js about it using the `sizes` prop. Without it, the browser might download a 1200px image for a column that's only 400px wide on mobile.

// ❌ Missing sizes — browser downloads a huge image for mobile
<div className="relative w-full">
  <Image
    src="/product.jpg"
    alt="Product"
    fill
    className="object-cover"
  />
</div>

// ✅ Correct — browser downloads the right size for each viewport
<div className="relative w-full">
  <Image
    src="/product.jpg"
    alt="Product"
    fill
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    className="object-cover"
  />
</div>

// For a responsive grid that's:
// - Full width on mobile
// - 50% width on tablet
// - 33% width on desktop

The `sizes` attribute maps directly to CSS media query logic. Think about how your image actually renders at different breakpoints and describe it. If you have a full-width hero, it's `100vw`. If you have a 3-column card grid, it's something like `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw`. Getting this right can cut your image payload on mobile by 60-70%.

AVIF vs WebP: Picking Your Format

By default, Next.js serves WebP to browsers that support it, and falls back to the original format for browsers that don't. You can add AVIF to the mix — it's a newer format that compresses 30-50% better than WebP at the same quality. The trade-off is that encoding AVIF is significantly more CPU-intensive, which means that first-request optimization penalty we mentioned earlier gets worse.

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

const nextConfig: NextConfig = {
  images: {
    // Add AVIF support (better compression, slower to encode)
    formats: ['image/avif', 'image/webp'],
    
    // Customize quality (default is 75)
    // Lower = smaller file, worse quality
    // We usually keep this at 80 for product images
    quality: 80,
    
    // Cache optimized images for 60 seconds minimum
    // Default is 60. Increase this for static assets.
    minimumCacheTTL: 60 * 60 * 24 * 7, // 1 week
    
    // Define which device sizes to generate
    // These map to the srcset breakpoints
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
}

export default nextConfig

Our recommendation: enable AVIF if you're on a managed platform like Vercel where compute isn't a direct cost concern. If you're self-hosting and CPU matters, stick with WebP. The difference is real but not dramatic enough to justify killing your server on a traffic spike.

Remote Images and the domains Config

If you're pulling images from an external CDN — Cloudinary, S3, a CMS like Sanity or Contentful — you need to whitelist the domains. The old `domains` array works, but `remotePatterns` is strictly better because it lets you match on protocol, hostname, port, and pathname.

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      // ✅ Specific bucket on S3
      {
        protocol: 'https',
        hostname: 'your-bucket.s3.eu-central-1.amazonaws.com',
        port: '',
        pathname: '/uploads/**',
      },
      // ✅ Cloudinary
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
        pathname: '/your-cloud-name/**',
      },
      // ✅ Sanity CDN
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
      },
    ],
  },
}

// ❌ Old way — works but no path restriction
// domains: ['res.cloudinary.com', 's3.amazonaws.com']
// This allows ANY path from those domains, which is a security risk
Using `remotePatterns` instead of `domains` isn't just about performance — it prevents someone from constructing a URL that proxies arbitrary images through your Next.js server. Always scope it to the specific paths you actually need.

The fill Layout Pattern for Dynamic Containers

One situation that used to cause us grief: images in a card grid where the aspect ratio needs to stay consistent regardless of the image's original dimensions. If you have a blog where authors upload their own cover images, you can't predict whether they'll be landscape, portrait, or square. You want all cards to look the same.

// A card component that handles any image aspect ratio gracefully
interface BlogCardProps {
  title: string
  coverImage: string
  excerpt: string
}

export function BlogCard({ title, coverImage, excerpt }: BlogCardProps) {
  return (
    <article className="rounded-lg overflow-hidden border border-gray-200">
      {/* 
        The key: parent div with aspect-ratio and relative positioning.
        fill makes the image cover the entire parent container.
        object-cover crops instead of squishing.
      */}
      <div className="relative aspect-[16/9]">
        <Image
          src={coverImage}
          alt={title}
          fill
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
          className="object-cover"
        />
      </div>
      <div className="p-4">
        <h2 className="font-semibold text-lg">{title}</h2>
        <p className="text-gray-600 mt-1">{excerpt}</p>
      </div>
    </article>
  )
}

// Works with:
// 1200x800 landscape photo ✅
// 800x1200 portrait photo ✅
// 500x500 square photo ✅
// All render at the same 16:9 aspect ratio

The `aspect-[16/9]` class on the wrapper is doing the heavy lifting here. The image fills the container, `object-cover` handles the cropping. You can swap in any aspect ratio — `aspect-square` for avatars, `aspect-[4/3]` for product photos, whatever fits your design.

Blur Placeholders That Don't Look Terrible

By default, images just pop in when they load. You can use `placeholder="blur"` to show a blurred preview while the full image loads. For local images, Next.js generates this automatically at build time — zero extra config. For remote images, you need to provide a `blurDataURL`.

// Local images: automatic blur, no extra work needed
import heroImage from '/public/hero.jpg'

<Image
  src={heroImage} // import gives you width, height, AND blurDataURL for free
  alt="Hero"
  priority
  placeholder="blur" // just works
/>

// Remote images: you need to provide the blurDataURL yourself
// Option 1: Generate a tiny base64 placeholder (10px version of the image)
// You can use sharp, plaiceholder, or a service like blurha.sh

// Option 2: Use a static low-quality placeholder
// This is a 1x1 pixel in your brand color, encoded as base64
const brandColorPlaceholder = 
  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='

<Image
  src="https://cdn.example.com/product.jpg"
  alt="Product"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL={brandColorPlaceholder}
/>

// Option 3 (best): Use plaiceholder to generate real blur hashes server-side
// import { getPlaiceholder } from 'plaiceholder'
// const { base64 } = await getPlaiceholder(imageUrl)
// Then pass base64 as blurDataURL

The plaiceholder library is worth the setup if you care about this. It generates a proper blurred thumbnail that actually looks like the image, not just a solid color. For a portfolio or product site where the visual experience matters, it's a nice touch.

A Quick Checklist Before You Ship

  • Add `priority` to every image that's visible above the fold without scrolling
  • Set `sizes` accurately whenever your image changes size across breakpoints, especially with `fill`
  • Use `remotePatterns` instead of `domains` for external images — scope it to specific paths
  • Enable AVIF in `formats` if you're on a platform where compute is free (Vercel, etc)
  • Increase `minimumCacheTTL` for images that don't change often — default 60 seconds is too low
  • Use `placeholder="blur"` for above-the-fold images to prevent layout shift
  • Always wrap `fill` images in a positioned container with explicit dimensions
  • Run PageSpeed Insights after changes — the LCP and CLS scores will tell you if it worked

If you want a head start, our templates at peal.dev already have all of this wired up correctly — image configs, blur placeholders for dynamic content, proper sizes attributes across the responsive grid components. It's the kind of thing that's easy to miss when you're setting up a project from scratch at 11pm.

The biggest wins are always `priority` on above-the-fold images and correct `sizes` on responsive images. Get those two right and you'll see your LCP drop noticeably. Everything else is polish.

One more thing worth knowing: Next.js image optimization counts against your Vercel usage if you're on a paid plan. If you're serving a lot of unique image URLs (e.g., user-uploaded content where every URL is different), consider offloading optimization to Cloudinary or imgix rather than running everything through `/_next/image`. For most apps this isn't a concern — but if you're building something with heavy user-generated content, it's worth thinking about before you get a surprising invoice.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders