Skip to content
Chapter 34Lesson 2

Images with next/image

Next.js platform primitives: next/image for responsive sizing, lazy loading, and format negotiation by default, and remotePatterns as the security gate for external sources.

Picture a product page with three images: a brand logo from your design system, a product photo someone uploaded to S3, and a customer’s avatar. A plain <img src=...> on all three takes on four problems at once, and two of them degrade a CLS or LCP score directly.

These are two of the Core Web Vitals , the metrics Google uses to score how a page feels. Every fix in this lesson maps to one of the four failures below.

You’ll build the picture in two passes: the local, bundled image first, since it carries no security surface, then the remote one, where the optimizer and its remotePatterns allowlist come in.

A plain <img> fails structurally in four ways:

  1. One size for everyone. A single src goes to a 4K desktop and a budget phone alike. The phone downloads a 2000px file to paint it at 360px, five times the bytes it needed.
  2. No reserved space. The browser doesn’t know the image’s dimensions until bytes arrive, so it lays the image out at zero height, then shoves everything below it down once the real size is known. That shove is CLS.
  3. No lazy-loading. Every <img> fetches on load. The native loading="lazy" attribute is opt-in, and even then it does nothing about sizing or format.
  4. No format negotiation. WebP and AVIF are often 25–35% smaller than the equivalent PNG or JPEG, but a raw <img> serves the original bytes regardless.

An experienced engineer fixes every one by hand: a srcset so each device gets a right-sized file, explicit dimensions to reserve the box, loading="lazy" below the fold with a preloaded hero, and a build step that emits AVIF and WebP. It’s doable, but tedious and easy to get subtly wrong on the third image of the eighth page.

That’s the gap next/image closes. It encodes that discipline as a component default, handling all four failures out of the box, and turns the most dangerous mistake, shipping an unsized image, into a type error you can’t compile past.

<img>

page jumps on load

while loading
after load image
↓ pushed down
next/image

box reserved up front

before & after load box reserved
✓ no shift
A plain <img> reserves no space and reflows the page when it loads — that reflow is CLS. next/image reserves the box up front, so nothing jumps.

Static imports, the four required props, and fill

Section titled “Static imports, the four required props, and fill”

Start with the simplest case: an image that ships with your app, such as a logo, an illustration, or a marketing graphic. You know it at build time and it lives in your repo, so next/image can do the most for the least effort.

The cleanest way to use one is a static import: you import the image file the way you’d import a module.

app/_components/site-header.tsx
import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
export const SiteHeader = () => (
<Image src={logo} alt="Acme" />
);

The import gives you not a string but a typed object, roughly { src, width, height, blurDataURL }. Next reads the file at build time, so it knows the intrinsic width and height and can reserve a correctly-shaped box without you typing a number. There’s no width or height prop on the <Image>: those values ride along on the imported object.

You also get a blurDataURL for free, baked into that same object.

app/_components/site-header.tsx
<img src="/logo.png" alt="Acme" />

Ships one fixed file to every device. No srcset, no reserved box, no lazy-loading, no format negotiation.

When src is a plain string: the four required props

Section titled “When src is a plain string: the four required props”

A static import is the happy path, but a remote URL is a runtime string, not a build-time import. The component can’t read the file ahead of time, so you supply what the import would have given it. Four props become required: src, width, height, and alt.

width and height are not the display size. They give the image’s intrinsic aspect ratio, the shape of the source, so the component can reserve a correctly-proportioned box. How big it actually renders is a CSS decision, made with a className as always.

app/_components/og-preview.tsx
<Image
src="https://assets.acme-cdn.com/banners/launch.png"
width={1200}
height={630}
alt="Spring launch banner"
className="w-full max-w-md rounded-lg"
/>

This image declares a 1200×630 ratio but renders at whatever max-w-md resolves to, a few hundred pixels. The numbers and the rendered size disagree on purpose: the component needs only the ratio, and CSS owns the rest.

alt is required by the type, not by convention: leave it off and the code won’t compile. For an image that carries meaning, describe it. For a purely decorative one, such as a background flourish or a divider, pass alt="" to tell screen readers “skip this, it adds nothing.”

fill for containers whose size you don’t know

Section titled “fill for containers whose size you don’t know”

Sometimes you genuinely can’t know the pixel dimensions: the image fills a card thumbnail sized by a responsive grid, or an avatar slot in a flex row. Hard-coding width and height would fight the layout. For these, use fill.

app/_components/product-card.tsx
<div className="relative h-40 w-full">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover"
/>
</div>

A fill image expands to cover its nearest positioned ancestor, which is why the wrapper has relative and a real height. The image stops carrying its own dimensions and absorbs the parent’s instead.

fill comes with one hard rule, and it’s the most common production mistake with next/image: a fill image requires a sizes prop. Leave it off and the browser, with no idea how wide the image will render, downloads the largest variant in the set, defeating the point of the component. The next section unpacks sizes. When you do know fixed dimensions, prefer them: fill is only for when you can’t.

The props so far, src, width and height or fill, and alt, are the floor. The next four are where the real performance lives.

The props you author: sizes, preload, placeholder, quality

Section titled “The props you author: sizes, preload, placeholder, quality”

These four props apply to every next/image, local or remote, which is why they sit between the two passes. Skip them and the performance win never materializes. Each one fixes a specific failure, so each is introduced here by the failure it prevents.

sizes: the prop that makes responsive images work

Section titled “sizes: the prop that makes responsive images work”

next/image generates a srcset, the same image at several widths, and lets the browser pick one. But the browser picks before it lays the page out, so it doesn’t yet know how wide your image will render. Without help it assumes the worst, that the image fills the viewport, and grabs the biggest candidate, the oversized-bytes failure the component was meant to fix.

sizes tells the browser up front how wide the image will be at each breakpoint. The value is a list of media conditions, read left to right, first match wins:

app/_components/gallery.tsx
<Image
src={photo.url}
alt={photo.caption}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>

This reads: at 1024px and up the image takes a third of the viewport width, from 640px up half, otherwise the full width. Each clause is the image’s rendered width, which you derive from your own layout. A three-column grid makes each cell roughly a third of the viewport, so 33vw; a two-column layout is 50vw; a full-bleed hero is 100vw. You’re not styling anything: you’re handing the browser the one number it can’t compute on its own, so it fetches a file that fits instead of one five times too big. fill images always need sizes, since there’s no width to fall back on.

sizes is an input to the browser’s pick, not a CSS size:

srcset

one set the optimizer generated

↑ phone 640w
↑ desktop 1080w
1920w
no sizes → both grab this
sizes

the width you hand the browser

Phone

sizes 100vw → slot ≈360px

fetches 640w

Desktop

sizes 33vw → slot ≈620px

fetches 1080w
sizes tells the browser how wide the image will render, so it can fetch the right srcset variant for each device instead of always grabbing the largest. Same set, two devices, two different files.

preload: for the one image the user sees first

Section titled “preload: for the one image the user sees first”

The hero at the top of the page, the lead product photo above the fold, is your LCP element, the biggest thing the user sees before anything else paints. By default next/image lazy-loads, which is right for the avatar three screens down but wrong for the hero. preload inserts a <link rel="preload"> into the document head, so the browser starts fetching the image before it discovers the <Image> in the body. That directly improves LCP.

app/(marketing)/page.tsx
<Image
src={hero.url}
alt="Dashboard overview"
width={1280}
height={720}
preload
sizes="100vw"
/>

One naming point trips you up in existing code. In Next.js 16 the prop is preload; older codebases and most tutorials call it priority. It was renamed so the name describes the effect, inserting a preload link, rather than a vague “this is important.” If you see priority, that’s the old name; write preload.

The discipline matters more than the syntax: preload exactly one image per page, the LCP candidate. Each preload jumps the fetch queue, so preloading ten images fetches all ten at once and your real hero waits its turn anyway. Pick the one.

placeholder="blur": perceived-performance polish for heros

Section titled “placeholder="blur": perceived-performance polish for heros”

A reserved box prevents the layout jump, but it stays empty until the bytes arrive. placeholder="blur" fills that gap with a tiny blurred preview while the full version streams in, so the user sees something image-shaped immediately. Static imports get the blur for free from blurDataURL; remote sources don’t ship one, so you supply a generated blur or an inline base64 string yourself.

The blur earns its bytes on a large hero or a media-heavy gallery, where the empty box would be glaring. It’s pure noise on a 32px avatar, so reserve it for images big enough that the blur registers.

quality and the Next.js 16 qualities allowlist

Section titled “quality and the Next.js 16 qualities allowlist”

quality controls the compression level, 1–100, defaulting to 75. You should almost never change it: 75 is a good balance and the eye rarely notices a difference above it. But in Next.js 16 how you change it changed.

Older versions let you pass any quality freely. The new default for images.qualities is the single value [75], and to use any other quality you must first add it to that allowlist in next.config.ts:

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
qualities: [50, 75, 90],
},
};
export default nextConfig;

The subtle part is what happens when they disagree. Write quality={90} but forget to add 90 to the array and the page doesn’t crash: the component quietly uses the closest allowed value, so you asked for 90, the array only allows 75, and you get 75. (Only hitting the optimizer’s raw endpoint with an unlisted quality returns a 400, which you won’t do by hand.) The image renders, nothing errors, and you’re left wondering why your “high quality” hero looks like the default. When you set a non-default quality, the prop and the config array must agree.

The allowlist exists to prevent abuse: with an unbounded range, an attacker can request one image at a thousand distinct quality values and explode your optimizer’s cache, since each variant is a separate cached object.

The upshot for upgraders: code that passed arbitrary quality values in Next.js 15 silently coerces to 75 in Next.js 16 until you widen the array. If your custom-quality images suddenly look softer after an upgrade, this is why.

This hero spans the full width of the viewport on every screen. Fill in the responsive sizes value and the prop that preloads it as the LCP image. Pick the right option from each dropdown, then press Check.

app/(marketing)/page.tsx
<Image
src={hero.url}
alt="Product hero"
width={1280}
height={720}
sizes={___}
___
/>

External images: the optimizer and the remotePatterns allowlist

Section titled “External images: the optimizer and the remotePatterns allowlist”

A remote image, such as a product photo on S3 or an asset on a CDN, can’t be a static import. It reaches the browser through a different path, and that path runs through the optimizer.

When src is a remote URL, the browser does not fetch it directly. It requests an endpoint on your own app, /_next/image?url=...&w=...&q=..., which fetches the original from its source, transcodes it to the format the browser negotiated (AVIF or WebP) at the requested width and quality, caches the result, and serves it back. Same srcset variants and modern formats as the static path, but produced on demand, through your server.

On Vercel this is the built-in Image Optimization: edge-cached and keyed by URL, width, quality, and format, so each variant is transcoded once. It’s also metered compute, not free, which is why you don’t want arbitrary images flowing through it.

The endpoint takes a url parameter and fetches it on your server’s behalf. If you let it fetch any URL, an attacker scripts a loop hitting /_next/image?url=<some-enormous-file-hosted-anywhere>&w=3840. Every request makes your server fetch a huge file, transcode it, and cache it, burning your compute, bandwidth, and optimizer bill, and turning your domain into a free open image proxy. remotePatterns closes that off.

In Next.js 16, any non-local image source requires a matching entry in images.remotePatterns. No entry, no fetch. Each entry describes an origin you trust through up to five fields: protocol, hostname, port, pathname, and search. Write one entry per real origin, each as tight as you can: exact hostname, protocol: 'https', and pathname narrowed to the prefix you actually serve from.

next.config.ts
const nextConfig: NextConfig = {
images: {
remotePatterns: [{ protocol: 'https', hostname: '**' }],
},
};

This is an open-proxy vulnerability. A wildcard hostname lets anyone route any URL through your optimizer, reopening the exact abuse the allowlist exists to prevent. Convenient in a five-minute tutorial, a real security hole in production. Never ship it.

Hover each field to see what it constrains:

next.config.ts
{
protocol: 'https',
hostname: 'uploads.acme-app.com',
pathname: '/avatars/**',
search: '',
}

New in Next.js 16, a local image served from your own origin whose src carries a query string needs a matching images.localPatterns entry, the same allowlist idea applied to your own paths. Most projects never hit it, since plain /public paths have no query string. But if you serve a local image with ?something on the end and get a refusal, that’s the config you’re missing.

The choice between both passes turns on provenance, where the asset came from:

  • Assets you control at build time, such as design-system logos, marketing graphics, and illustrations, go in /public or a static import. No remotePatterns needed; they’re already yours.
  • Assets fetched at runtime, anything a user uploaded or anything from a third party, use a remote src plus a remotePatterns entry for its origin. Never a wildcard host.

Your app shows two images on a profile page — your company’s logo, which ships in the repo, and the user’s uploaded avatar, stored on S3. How should each be configured?

Logo: a static import from the repo, no remotePatterns. Avatar: a remote src plus a remotePatterns entry for the S3 origin.
Both as static imports — bundle the avatar with the app so it loads fastest.
Both as remote src, with a single remotePatterns entry using hostname: '**' to cover both.
Logo as a remote src from your CDN; avatar as a static import once the user uploads it.

The optimizer’s transforms, and its limits

Section titled “The optimizer’s transforms, and its limits”

Format negotiation. The optimizer reads the browser’s Accept header and serves AVIF or WebP where supported, falling back to the original otherwise. images.formats sets the menu; the default is ['image/webp']. AVIF compresses about 20% smaller than WebP but encodes about 50% slower, which shows up as first-request latency on a cold cache. For most SaaS surfaces, WebP-only (the default) is the pragmatic pick. Add AVIF only when your asset library is large and bandwidth is the dominating cost.

deviceSizes and imageSizes. These set the exact widths the optimizer generates for the srcset. The defaults already cover the breakpoints a normal app cares about, so recognize the names in a config but leave them alone until profiling shows a real gap.

What the optimizer does not do. Width, quality, and format are the only transforms: no cropping, no overlays, no watermarks, no text, no smart-cropping to a focal point. The moment you need one of those, next/image is the wrong tool. Reach instead for a dedicated image service such as Cloudinary, Imgix, or Cloudflare Images, or run sharp in a background job.

unoptimized bypasses the optimizer and serves the original bytes untouched, which is legitimate for an already-optimized asset where re-encoding would burn compute for no gain.

SVGs get this treatment automatically: Next serves any src ending in .svg as unoptimized by default, for two reasons. SVGs are a vector format, so they resize losslessly and there’s nothing to transcode. They can also carry embedded script, which makes them an XSS vector.

To force SVGs through the optimizer you set images.dangerouslyAllowSVG: true; the dangerously prefix is the warning. Doing it safely also means pairing it with contentDispositionType: 'attachment' and a locked-down contentSecurityPolicy so an embedded script can’t execute.

Off Vercel. The optimizer needs a sharp-capable function to run. Deploy somewhere without one and you wire a loader (or loaderFile) that points next/image at an external image CDN instead.

Worked example: three images on one product page

Section titled “Worked example: three images on one product page”

All three paths on one page, so the decisions sit side by side.

import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
export const ProductPage = ({ product, customer }: ProductPageProps) => (
<main>
<header>
<Image src={logo} alt="Acme" />
</header>
<Image
src={product.imageUrl}
width={1280}
height={720}
alt={product.name}
sizes="(min-width: 1024px) 66vw, 100vw"
quality={90}
preload
/>
<figure className="relative h-10 w-10 overflow-hidden rounded-full">
<Image src={customer.avatarUrl} alt={customer.name} fill sizes="40px" />
</figure>
</main>
);

Logo → static import, no preload. It ships in the repo, so the import carries its dimensions and needs no config. It’s small and not the LCP element, so no preload.

import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
export const ProductPage = ({ product, customer }: ProductPageProps) => (
<main>
<header>
<Image src={logo} alt="Acme" />
</header>
<Image
src={product.imageUrl}
width={1280}
height={720}
alt={product.name}
sizes="(min-width: 1024px) 66vw, 100vw"
quality={90}
preload
/>
<figure className="relative h-10 w-10 overflow-hidden rounded-full">
<Image src={customer.avatarUrl} alt={customer.name} fill sizes="40px" />
</figure>
</main>
);

Product photo → remote + preload + custom quality. A remote src needs a remotePatterns entry; width/height fix the ratio; sizes matches its two-thirds slot; preload marks it the hero. quality={90} is why the config also needs a qualities array.

import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
export const ProductPage = ({ product, customer }: ProductPageProps) => (
<main>
<header>
<Image src={logo} alt="Acme" />
</header>
<Image
src={product.imageUrl}
width={1280}
height={720}
alt={product.name}
sizes="(min-width: 1024px) 66vw, 100vw"
quality={90}
preload
/>
<figure className="relative h-10 w-10 overflow-hidden rounded-full">
<Image src={customer.avatarUrl} alt={customer.name} fill sizes="40px" />
</figure>
</main>
);

Avatar → fill + sizes. Its container is a fixed 40px circle, so fill lets the image absorb that box instead of carrying its own dimensions. With fill, sizes="40px" is mandatory, or the browser fetches the largest variant for a thumbnail.

1 / 1

Two images are remote and one asks for non-default quality, so the prop and the config entry have to agree.

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'assets.acme-cdn.com', pathname: '/products/**' },
{ protocol: 'https', hostname: 'uploads.acme-app.com', pathname: '/avatars/**' },
],
qualities: [75, 90],
},
};
export default nextConfig;

Miss the remotePatterns entry and the optimizer refuses the image outright. Miss the qualities entry and it doesn’t error: it silently serves 75, and you’re left wondering why the hero looks soft.

What to remember: provenance and three reflexes

Section titled “What to remember: provenance and three reflexes”

The durable takeaway is provenance: assets you ship use a static import; assets users upload use a remote src with a scoped remotePatterns, never a wildcard host.

Three reflexes are the review mistakes that recur:

  1. sizes on every fill image, or the browser fetches the biggest variant and optimization unravels.
  2. preload on exactly the LCP image, one per page, or the signal drowns.
  3. An explicit hostname for every external origin, never '**', which hands an attacker your optimizer.