DevStacked
nextjsJuly 21, 202620 min read

Image Optimization in Next.js 16: The Complete 2026 Guide

If you've ever shipped a page with a 2.5MB hero image and watched your Lighthouse score crash to single digits, you already know why this matters. A slow, unoptimized image is one of the fastest ways to lose a visitor — most people won't wait more than a couple of seconds for a page to feel usable, and a giant JPEG sitting above the fold is usually the culprit.

The good news: Next.js has had a built-in fix for this since version 10, and Next.js 16 makes it stricter, safer, and a little different from what you might remember. If you've been using priority or skipping qualities config, some of that has changed.

By the end of this guide, you'll know exactly how the next/image component works in Next.js 16, how to configure it correctly for local and remote images, and how to avoid the security-related gotchas that were added in this version.

<img> vs next/image at a Glance

Before diving in, here's what you actually get by switching:

Feature<img>next/image
Automatic resizing per device❌ Manual✅ Automatic
Format conversion (WebP/AVIF)❌ Manual✅ Automatic
Lazy loading⚠️ Basic (loading="lazy" only)✅ Built-in, smarter defaults
Layout shift (CLS) prevention❌ Manual✅ Enforced via width/height
Responsive srcset generation❌ Manual✅ Automatic
Blur-up placeholder❌ Manual✅ Built-in for local images
Caching of optimized variants❌ None✅ Disk cache with configurable TTL
Remote source security❌ NoneremotePatterns allowlist

That table alone is the reason next/image exists — everything on the right side would otherwise be work you'd have to build and maintain yourself.

Why Image Optimization Is Tricky

Browsers don't know how big your users' screens are ahead of time. Serve one giant image and you're wasting bandwidth on mobile. Serve a tiny one and desktop users get blurry visuals. On top of that, modern formats like WebP and AVIF can shrink file sizes by 30–70% compared to JPEG/PNG — but only if you actually generate and serve them.

Doing all of this by hand (multiple sizes, multiple formats, lazy loading, layout shift prevention) is a lot of manual work. The next/image component automates it: one component, and Next.js generates the right size, right format, and right loading behavior automatically.

💡 Tip: Every layout-shift issue you've seen from images loading in late is what the width/height requirement on <Image> is designed to prevent.

Prerequisites

This guide assumes:

  • Next.js 16 with the App Router
  • TypeScript (strict mode)
  • Basic familiarity with React components

If you're on an older version, run the upgrade codemod first:

npx @next/codemod@canary upgrade latest

Step 1: Replace <img> with next/image

Instead of a plain <img> tag, import Image from next/image.

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

export default function Page() {
  return (
    <Image
      src="/profile.png"
      width={500}
      height={500}
      alt="Picture of the author"
    />
  )
}

What's happening here: src points to a file in your public/ folder. width and height are the image's intrinsic dimensions (in pixels) — not the size it renders at on screen. Next.js uses these to calculate the aspect ratio and reserve space in the layout before the image finishes loading, which prevents the page from jumping around (this jumping is called Cumulative Layout Shift, or CLS — a metric Google uses to judge page experience).

⚠️ Common Mistake: Beginners often skip width/height because "the image already has a size." Next.js still needs these numbers explicitly, unless you use a static import or the fill prop (both covered below).

Step 2: Local vs. Static Imports

There are two ways to reference a local image, and they behave differently.

Option A — string path (you provide width/height manually):

<Image src="/hero.jpg" width={1200} height={600} alt="Hero banner" />

Option B — static import (Next.js infers width/height automatically):

// app/page.tsx
import Image from 'next/image'
import hero from '../public/hero.jpg'

export default function Page() {
  return <Image src={hero} alt="Hero banner" />
}

What's happening here: With a static import, Next.js reads the file at build time, figures out its real dimensions, and even auto-generates a small blurred placeholder (blurDataURL) for JPG, PNG, WebP, or AVIF files — with zero extra config. This is the easiest and safest way to handle local images.

Step 3: Configure Remote Images with remotePatterns

If your image comes from an external URL (a CMS, S3 bucket, CDN), Next.js won't optimize it by default — for security reasons, you have to explicitly allow it.

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

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

export default nextConfig
// app/page.tsx
<Image
  src="https://assets.example.com/uploads/profile.png"
  alt="Picture of the author"
  width={500}
  height={500}
/>

What's happening here: remotePatterns is a strict allowlist. Any external image URL that doesn't match one of these patterns gets rejected with a 400 Bad Request. Since remote images aren't available at build time, you must supply width and height manually — Next.js can't inspect a file it doesn't have locally.

⚠️ Common Mistake: Don't leave remotePatterns wide open (e.g. matching ** with no protocol or pathname) — that lets anyone route arbitrary external URLs through your server's image optimizer. Be as specific as possible with hostname and pathname.

⚠️ Common Mistake: If your src image requires authentication (a private bucket, a signed CMS endpoint), the default loader won't work — for security reasons, Next.js does not forward headers when fetching the source image. In that case, set the unoptimized prop on that image instead of trying to pass auth headers through.

Step 4: Responsive Images with sizes

By default, if you don't tell Next.js how wide the image will actually render, it assumes it could be as wide as the entire viewport — and generates a larger srcset than necessary.

<Image
  src="/banner.jpg"
  alt="Banner"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

What's happening here: sizes tells the browser: "on small screens, this image is roughly full width; on medium screens, half width; otherwise a third of the width." The browser then picks the most appropriately sized file from the generated srcset instead of always downloading the largest version.

Use sizes whenever:

  • You're using the fill prop, or
  • The image is styled to be responsive via CSS

Step 5: fill for Unknown Dimensions

Sometimes you don't know an image's dimensions ahead of time — a user-uploaded avatar, for example.

<div style={{ position: 'relative', width: '400px', height: '300px' }}>
  <Image
    src="/uploaded-photo.jpg"
    alt="User upload"
    fill
    sizes="400px"
    style={{ objectFit: 'cover' }}
  />
</div>

What's happening here: fill makes the image expand to match its parent container, so the parent must have position: relative, absolute, or fixed. objectFit: 'cover' crops the image to fill the container without distorting it — similar to CSS background-size: cover.

Step 6: Choose the Right Loading Strategy

Not every image on a page deserves the same treatment. Next.js gives you four levers, and picking the right one per image matters more than most beginners realize.

StrategyHow to set itWhen to use it
Lazy (default)Nothing — loading="lazy" is automaticAny image below the fold: galleries, article body images, footer content
Eagerloading="eager"An above-the-fold image you want loaded immediately, without waiting for scroll distance to trigger it
High fetch priorityfetchPriority="high"Your actual LCP candidate in most cases — this is what Next.js itself recommends reaching for first
PreloadpreloadA narrower case: you specifically want the browser to start fetching the image from the <head>, before it's even discovered in the HTML body — and you're certain only one image qualifies
// Below the fold — do nothing, lazy is default
<Image src="/gallery-1.jpg" width={400} height={300} alt="Gallery photo" />

// Above the fold but not the LCP element — load immediately, don't wait for lazy trigger
<Image src="/secondary-banner.jpg" width={800} height={300} alt="Banner" loading="eager" />

// The confirmed LCP element — Next's own recommendation for most cases
<Image src="/hero.jpg" width={1200} height={600} alt="Hero" fetchPriority="high" />

What's happening here: Lazy loading defers off-screen images until the browser gets close to them, saving bandwidth on page load. Eager loading forces an image to load immediately regardless of position — useful just below the fold. fetchPriority="high" bumps an image's priority in the browser's normal fetch queue without the stricter constraints of preload.

⚠️ Correction worth noting: It's tempting to reach for preload as the default "make my hero load fast" fix, but Next.js's own docs say the opposite: "In most cases, you should use loading='eager' or fetchPriority='high' instead of preload." Reserve preload for cases where you're certain exactly one image is the LCP element and you specifically need the <head> preload behavior — and never combine it with loading or fetchPriority on the same image, since Next.js explicitly warns against that combination.

💡 Real example from the docs: If you're swapping images based on light/dark theme (two <Image> components, one hidden via CSS), you can't use preload or loading="eager" on either one — both would end up loading. fetchPriority="high" is the only safe option there, since it doesn't force an eager fetch the way the other two do.

Step 7: Controlling Quality — qualities Is Now Required Config

Another Next.js 16 change: the qualities array in next.config.ts is now required if you want to use any quality value other than the default.

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    qualities: [25, 50, 75, 100],
  },
}
<Image src="/photo.jpg" width={800} height={600} alt="Photo" quality={90} />

What's happening here: Previously, you could pass any quality value (1–100) freely. Starting in Next.js 16, the value you pass to quality must match one of the values listed in qualities in your config — otherwise Next.js snaps to the closest allowed value. This exists because unrestricted quality values could be abused to generate an unlimited number of cached image variants, wasting server resources.

💡 Tip: If you don't configure qualities at all, the default allowed value is [75] — meaning quality={90} would silently be clamped down to 75.

Step 8: Choosing Output Formats — AVIF vs. WebP

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
  },
}

What's happening here: Next.js checks the browser's Accept header and serves whichever format is listed first in your array that the browser supports. AVIF compresses roughly 20% smaller than WebP but takes about 50% longer to encode on first request (subsequent requests are cached, so this only affects the very first visitor per image size/format combination). For most projects, WebP alone is a solid, faster-to-generate default — add AVIF if you want to squeeze out extra savings on high-traffic pages.

⚠️ Common Mistake: Enabling both formats doubles your optimized-image storage, since Next.js caches AVIF and WebP versions separately. Keep an eye on maximumDiskCacheSize if disk space is a concern.

Step 9: Blur Placeholders for a Smoother Load

<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSk..."
/>

What's happening here: placeholder="blur" shows a blurred, low-res version of the image while the full one loads, which feels smoother than a blank space. For statically imported local images, Next.js generates blurDataURL for you automatically. For remote or dynamic images, you need to generate a small base64 placeholder yourself (a tool like Plaiceholder can do this).

Step 10: SVGs and Skipping Optimization

SVGs are vector graphics — resizing them losslessly means there's nothing for the optimizer to do. Next.js skips optimizing them by default and recommends the unoptimized prop:

<Image src="/logo.svg" width={120} height={40} alt="Logo" unoptimized />

What's happening here: unoptimized tells Next.js to serve the file exactly as-is, skipping resizing, format conversion, and quality adjustment. This also applies to tiny images (under 1KB) and animated GIFs, where optimization overhead outweighs any benefit.

Step 11: Preserving SEO Rankings with overrideSrc

By default, next/image rewrites the rendered src attribute to point at Next's optimization endpoint (/_next/image?url=...) instead of the original file path. For a brand-new site that's irrelevant — but if you're migrating an already-live, already-indexed site from <img> to <Image>, it's worth knowing this changes the URL search engines see for that image.

To be precise about what this does and doesn't mean: changing an image's URL doesn't erase or "reset" its rankings — search engines are perfectly capable of discovering and reindexing a new URL on their own, and image search visibility isn't tied to the URL string itself. What it does mean is a gap while the new URL gets recrawled and reindexed, and any direct backlinks or bookmarks pointing at the old file path stop resolving to the same indexed result unless you redirect them. For a high-traffic image ranking well in image search, that gap is often reason enough to avoid it.

<Image
  src="/uploads/dental-clinic-hero.jpg"
  overrideSrc="/uploads/dental-clinic-hero.jpg"
  width={1200}
  height={600}
  alt="Modern dental clinic reception area"
/>

What's happening here: Next.js still generates the optimized srcset behind the scenes, but overrideSrc forces the final src attribute in the rendered HTML to stay exactly what you specify — so the URL search engines already have indexed doesn't change, and there's no recrawl gap to wait out. Users still get the responsive, optimized images either way.

💡 Tip: This is mainly worth reaching for on pages that already get meaningful image search traffic. For most other migrations, letting the URL change and get reindexed naturally is completely fine.

Step 12: Custom Markup with getImageProps()

Sometimes you don't want Next.js to render the <img> tag for you — maybe you need a <picture> element for art direction, or want to set a CSS background image using an optimized source. getImageProps() gives you the computed props without rendering anything.

// app/components/theme-image.tsx
import { getImageProps } from 'next/image'

const { props } = getImageProps({
  src: '/mountain.jpg',
  alt: 'A scenic mountain view',
  width: 1200,
  height: 800,
})

function ImageWithCaption() {
  return (
    <figure>
      <img {...props} />
      <figcaption>A scenic mountain view</figcaption>
    </figure>
  )
}

What's happening here: getImageProps() runs the same logic <Image> uses internally — generating srcset, src, and sizing — and just hands you the resulting props object instead of a component. This is the tool to reach for with <picture> elements, CSS image-set() backgrounds, or any case where you need full control over the rendered markup while still getting optimized URLs.

💡 Tip: getImageProps() can't be combined with the placeholder prop, since there's no component lifecycle to remove the placeholder once the image loads.

Advanced Configuration

The following options aren't things you'll touch on day one, but they're worth knowing about once your app is live and you run into a specific problem.

Custom Image Loaders (Skipping Next's Built-In Optimizer)

If you're already using a CDN that does its own image optimization — Cloudinary, Imgix, a headless CMS's built-in image API — you don't need Next.js to optimize on top of it. A loader function builds the URL yourself instead of using Next's /_next/image endpoint:

// lib/cloudinary-loader.ts
export default function cloudinaryLoader({ src, width, quality }: { src: string; width: number; quality?: number }) {
  return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`
}
// next.config.ts
const nextConfig: NextConfig = {
  images: {
    loader: 'custom',
    loaderFile: './lib/cloudinary-loader.ts',
  },
}

What's happening here: loaderFile applies this URL-building function to every <Image> in the project, so you don't need to configure remotePatterns or lean on Next's own optimizer at all — the CDN handles resizing and format conversion on its end.

Caching Behavior with minimumCacheTTL

Optimized images are cached on disk with a default TTL of 4 hours (minimumCacheTTL in next.config.ts). If you've replaced an image file but the old version is still showing, this is usually why — the actual expiry used is whichever is larger: minimumCacheTTL or the upstream image's own Cache-Control header. There's no built-in way to manually invalidate a specific cached image, so keeping this value low is safer if your images change often.

Self-Hosting Behind a Proxy or CDN

If you self-host Next.js behind your own reverse proxy or CDN, AVIF/WebP format negotiation can silently stop working — Next.js decides which format to serve based on the request's Accept header, and if your proxy doesn't forward that header through, every browser ends up getting the original format regardless of what it actually supports. Worth checking if you notice formats config seemingly having no effect in production.

Real-World Example: A Blog Post Page

Here's how several of these pieces come together on an actual page — a blog post with one hero image (the LCP element) and a few inline body images that can load lazily.

// app/blog/[slug]/page.tsx
import Image from 'next/image'
import heroImage from '../../../public/blog/hero.jpg'

export default function BlogPost() {
  return (
    <article>
        {/* Hero — above the fold
            Uses a static import
            Automatically gets blurDataURL
            Marked as high priority
        */}

      <Image
        src={heroImage}
        alt="Team collaborating around a whiteboard"
        sizes="100vw"
        fetchPriority="high"
        style={{ width: '100%', height: 'auto' }}
      />

      <h1>How We Scaled Our Front-End Team</h1>
      <p>Some intro text about the article...</p>

        {/* Inline content images
            Lazy-loaded by default
            Remote URLs
            Need remotePatterns
        */}

      <Image
        src="https://assets.devstacked.tech/blog/diagram-1.png"
        alt="Team structure diagram"
        width={800}
        height={450}
        sizes="(max-width: 768px) 100vw, 800px"
      />

      <p>More article content...</p>

      {/* Another inline image, further down */}
      <Image
        src="https://assets.devstacked.tech/blog/screenshot-1.png"
        alt="Dashboard screenshot"
        width={800}
        height={500}
        sizes="(max-width: 768px) 100vw, 800px"
      />
    </article>
  )
}

What's happening here: Only the hero gets fetchPriority="high", since it's the confirmed LCP element — following Next's own guidance to reach for fetchPriority over preload in the common case. The static import means Next.js already knows its dimensions and generates a blur placeholder automatically. The two inline images come from a remote CMS bucket (so they'd need a matching remotePatterns entry in next.config.ts), have manually supplied width/height, and are left with the default lazy loading since they're further down the page — no fetchPriority, no eager, just sensible defaults.

Image Optimization Checklist

Run through this before shipping a page with images:

  • Every <img> tag replaced with next/image, or a documented reason why not (e.g. a tiny inline icon)
  • width and height set on every image, or a static import / fill used instead
  • External image domains added to remotePatterns — as narrow as possible, not wildcarded
  • sizes set on any image using fill or styled to be responsive
  • LCP image marked with fetchPriority="high" (Next's preferred approach), with preload reserved for the narrower case where you need <head>-level preloading and are certain only one image qualifies
  • qualities configured in next.config.ts if you use any quality value besides 75
  • formats set to ['image/webp'] at minimum; add AVIF only if the extra storage/encoding cost is worth it for your traffic
  • alt text is descriptive and meaningful for every image (empty string only for purely decorative images)
  • SVGs and tiny/animated images marked unoptimized instead of routed through the optimizer
  • blurDataURL provided for remote/dynamic images where a blur placeholder is used
  • overrideSrc considered for high-traffic, already-indexed images where you want to skip the recrawl gap entirely

Frequently Asked Questions

Do I still need to use the priority prop?

priority still technically works but is deprecated as of Next.js 16. That said, don't just swap it 1:1 for preload — Next.js recommends fetchPriority="high" (or loading="eager") for most LCP images, and reserves preload for narrower cases. See Step 6 for the breakdown.

Should I use preload for my hero/LCP image?

Usually not directly — Next's own docs say to use loading="eager" or fetchPriority="high" in most cases instead. preload is meant for a narrower situation: when you're certain exactly one image is the LCP element and specifically want it fetched from the <head> before the rest of the page is parsed.

How do I keep my image SEO rankings when migrating from <img> to next/image?

Changing an image's URL doesn't reset or erase its rankings — search engines can rediscover and reindex a new URL fine on their own. What it does mean is a recrawl/reindex gap, and any existing backlinks pointing at the old file path won't resolve the same way. Use the overrideSrc prop to keep the original file path as the rendered src attribute (Next.js still serves the optimized srcset behind the scenes) if you want to skip that gap entirely — mainly worth it for pages already getting real image search traffic.

Why is my quality prop being ignored?

As of Next.js 16, quality values must match an entry in your qualities array in next.config.ts. If you haven't configured it, the only allowed value is 75, so anything else gets rounded to the nearest allowed value.

Can I use next/image with images from an external CMS?

Yes, but you must add the CMS's domain to remotePatterns in next.config.ts, and you'll need to supply width and height manually since Next.js can't inspect a remote file at build time.

💡 Tip: If your CMS provides image dimensions in its API response, pass those directly instead of hardcoding fixed values.

Should I always use AVIF for the best compression?

Not necessarily. AVIF gives smaller files but takes longer to encode on first request. For most sites, WebP alone (the default) is a good balance of speed and size — add AVIF if performance budgets are tight and you can absorb the extra caching cost.

What's the difference between width/height and the actual rendered size?

width and height only tell Next.js the image's intrinsic aspect ratio, used to prevent layout shift — they don't control how big the image appears on screen. Actual rendered size is controlled by CSS (style or className).

Wrapping Up

That's the full picture of image optimization in Next.js 16: swap <img> for <Image>, lock down remote sources with remotePatterns, allowlist your quality values with qualities, and reach for fetchPriority="high" over preload for your LCP image in most cases. These changes exist mostly to close security gaps from earlier versions, so if you're migrating an older project, qualities and remotePatterns are the two configuration changes developers most commonly encounter when migrating to Next.js 16 — with overrideSrc worth a look too if you're migrating a page that already ranks in image search.

Next up, it's worth pairing this with a proper Metadata API setup for full page-level SEO, or diving into Cache Components to control how those optimized images and pages get cached at the edge.

Continue Learning

Free SEO Tools

image-optimizationperformancewebdevtypescript
Share On