Next.js Image Optimization Basics

`next/image` is the single biggest performance win you get for free — if you actually configure it. Here are the four things to set right.

Images are the heaviest thing on most content pages and the most common LCP offender. next/image automates responsive sizing, modern formats, lazy loading, and CDN delivery. Out of the box it covers most cases — but the defaults still trip people up on remote images, LCP priority, and host configuration.

Background

Next.js 15 ships next/image with sharp-based optimization, automatic AVIF/WebP, blur placeholders, and built-in lazy loading. On Vercel the optimization runs at the edge. On other hosts you either run a Node server, configure a loader, or pre-build optimized assets. Get the host story right first.

How to tell

  • PageSpeed Insights flags “Properly size images” or “Serve images in next-gen formats”.
  • Your LCP element is a hero image and LCP is over 2.5s on mobile.
  • next/image throws “Invalid src prop, hostname is not configured” on a deploy.
  • You see Cumulative Layout Shift caused by images loading after text.

Step by step

  1. Replace <img> with next/image. Static-imported images get free width/height inference and a built-in blur placeholder:
import Image from 'next/image';
import heroImg from '@/public/hero.png';

export default function Page() {
  return (
    <Image
      src={heroImg}
      alt="Dashboard showing weekly metrics"
      placeholder="blur"
      sizes="(max-width: 768px) 100vw, 800px"
      priority    // LCP image only — see step 2
    />
  );
}
  1. LCP image gets priority. This opts out of lazy loading and adds fetchpriority="high". Only one or two images per page should be marked — typically the hero, never a footer logo.

  2. Remote images need remotePatterns. Be specific, not **:

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 64, 96, 128, 256, 384],
    minimumCacheTTL: 31536000,        // 1 year — assets are content-hashed
    remotePatterns: [
      { protocol: 'https', hostname: 'images.unsplash.com', pathname: '/photos/**' },
      { protocol: 'https', hostname: 'cdn.yourdomain.com',  pathname: '/**' },
    ],
  },
};

export default nextConfig;
  1. sizes matters. Without it, the browser fetches the largest image in deviceSizes regardless of layout. Sample for a typical article hero (full width on mobile, ~800px on desktop):
<Image
  src={heroImg}
  alt="..."
  sizes="(max-width: 640px) 100vw,
         (max-width: 1024px) 80vw,
         800px"
  priority
/>
  1. fill needs a positioned parent. Otherwise the image collapses to zero height. Required for unknown aspect ratios:
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
  <Image src="/cover.jpg" alt="..." fill style={{ objectFit: 'cover' }} sizes="100vw" />
</div>
  1. Non-Vercel host? Pick a loader strategy. Static export forces a custom loader or unoptimized:
// Cloudflare Images / Imgix / custom loader example
const nextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/image-loader.ts',
  },
};
// lib/image-loader.ts
export default function loader({ src, width, quality }) {
  return `https://imgproxy.yourdomain.com/${width}/q${quality ?? 75}${src}`;
}

Or for a fully static export, give up optimization and gain portability:

const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
};
  1. Verify with DevTools Network tab. The hero should request once with image/avif content-type and fetchpriority: high:
# Check from terminal too
curl -sI 'https://yourdomain.com/_next/image?url=/hero.png&w=1080&q=75' \
  | grep -iE 'content-type|cache-control'
# content-type: image/avif
# cache-control: public, max-age=31536000, immutable
  1. Measure LCP regression. Lighthouse points at the actual largest paint element:
npx lighthouse https://yourdomain.com/articles/foo/ \
  --only-categories=performance --chrome-flags=--headless --quiet \
  | grep -A1 'largest-contentful-paint\|LCP element'

Common pitfalls

  • Forgetting priority on the LCP image — lazy loading delays the most important fetch, tanking LCP.
  • Whitelisting ** in images.remotePatterns — every random hostname becomes an optimization target, increasing attack surface and costs.
  • Using fill without a positioned parent (relative, absolute, or fixed) — image collapses to zero height.
  • Not setting sizes and shipping a 2000px-wide image to a 400px-wide phone column.
  • Hosting on a non-Vercel platform without a loader configured, then wondering why optimization “does not work”.
  • Disabling optimization (unoptimized: true) site-wide as a fix for one bad image — you lose the whole point of next/image.

Who this is for

Any Next.js site shipping images — blogs, marketing pages, product galleries, doc sites with diagrams.

When to skip this

Pure text sites with zero images (rare). Still configure next/image defaults in case you add some later.

FAQ

  • Does next/image work outside Vercel?: Yes — with a Node server it optimizes at request time, or you can use a custom loader (Cloudflare, Imgix). Static export needs an external loader.
  • Is AVIF worth it over WebP?: Yes for photographic content — AVIF is 20-30% smaller at similar quality. next/image serves AVIF when the browser supports it.
  • Should I use blur placeholders?: Yes for above-the-fold images — they reduce perceived load time. Static imports get them automatically.
  • What about SVGs?: Inline them or use <img>. next/image does not optimize SVG and disables it by default for security.

Tags: #Indie dev #Next.js #Core Web Vitals #Technical SEO #Getting started