Images with next/image
Next.js platform primitives, the next/image component that bakes responsive sizing, lazy loading, and format negotiation into a default, and remotePatterns as the security gate for external image sources.
Picture a single product page. It renders three images: a brand logo from your design system, a product photo someone uploaded to S3, and a customer’s avatar. If you reach for a plain <img src=...> on all three, you take on four problems at once. You ship a 2000px file to a 360px phone, which wastes bytes and slows the load. The layout jumps as each image arrives, because the browser never reserved a box for it (CLS ). Nothing lazy-loads, so an off-screen image competes for bandwidth with the hero the user is actually looking at (LCP ). And the format is whatever was uploaded: a PNG stays a PNG even when the browser would happily take a smaller AVIF.
CLS and LCP are two of the Core Web Vitals , the metrics Google uses to score how a page feels. You’ll measure them properly much later in the course. For now you only need the two failure modes by name, because every fix in this lesson maps to one of them.
This lesson makes one claim: next/image is the platform default that makes all four fixes automatic, and remotePatterns is the security gate that any external image source has to pass before it’s allowed through. You’ll build the picture in two passes. The local, bundled image comes first, because it carries no security surface. The remote one comes second, where the optimizer and its gate come in.
Why a plain <img> is the wrong default
Section titled “Why a plain <img> is the wrong default”It’s worth understanding why before reaching for the fix. Once you’ve seen the four failures up close, the component stops looking like ceremony and starts looking like a relief.
A plain <img> fails structurally in four ways:
- One size for everyone. You give it a single
src. That same file goes to a 4K desktop and a budget phone alike. The phone downloads a 2000px image to paint it at 360px, five times the bytes it needed, every time. - No reserved space. The browser doesn’t know the image’s dimensions until the bytes start arriving. So it lays the page out with the image at zero height, then, once the file loads and reveals its real size, shoves everything below it down to make room. That shove is CLS. The user’s thumb was over a button; now it’s over an ad.
- No lazy-loading discipline. Every
<img>on the page fetches immediately, on load. The nativeloading="lazy"attribute exists, but it’s opt-in: you have to remember it on every tag, and even then it does nothing about sizing or format. - Whatever format the source happens to be. Browsers have supported WebP and AVIF for years, and those formats are often 25–35% smaller than the equivalent PNG or JPEG. A raw
<img>serves the original bytes regardless, so you leave the saving on the table.
None of these are unfixable. An experienced engineer building a page by hand fixes every one. They generate a srcset so each device gets a right-sized file. They set explicit dimensions so the box is reserved. They add loading="lazy" below the fold and preload the hero. They run the images through a build step that emits AVIF and WebP. It’s all doable, but it’s tedious, easy to forget, and easy to get subtly wrong on the third image of the eighth page.
That’s exactly the gap next/image closes. It encodes the discipline of a careful engineer as a component default. It handles all four failures out of the box, and it turns the most dangerous mistake, shipping an unsized image, into a type error you can’t compile past.
The failure that’s hardest to picture in prose is the layout jump, so here it is as a diagram.
page jumps on load
box reserved up front
The local image: static imports and the four required props
Section titled “The local image: static imports and the four required props”Start with the simplest complete case: an image that ships with your app, such as a logo, an illustration, or a marketing graphic. You know it at build time, it lives in your repo, and it carries zero security surface. This is where next/image gives you the most for the least effort.
The cleanest way to use one is a static import, where you import the image file the same way you’d import a module:
import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
export const SiteHeader = () => ( <Image src={logo} alt="Acme" />);That import doesn’t give you a string. It gives you a typed object, roughly { src, width, height, blurDataURL }. Because Next reads the file at build time, it already knows the image’s intrinsic width and height, so it can reserve the right-shaped box without you typing a single number. Notice there’s no width or height prop on that <Image>: those values ride along on the imported object. That’s the whole appeal of the static path. The component knows everything it needs, and you wrote almost nothing.
You also get a blurDataURL for free, baked right into that object. It comes back into play a little later.
Now compare the before and after directly, because the contrast is the point.
<img src="/logo.png" alt="Acme" />Ships one fixed file to every device. No srcset, no reserved box, no lazy-loading, no format negotiation. It’s also the course’s banned pattern: an ESLint rule rejects a raw <img> later. It appears here only as the before-state.
import Image from 'next/image';
import logo from '@/app/_assets/logo.png';
<Image src={logo} alt="Acme" />Sized, lazy below the fold, format-negotiated, with a free blur, and the box reserved. One import (import Image from 'next/image', your first sight of it) and one component handle all four <img> failures by default. The dimensions ride on the imported object, so you don’t pass width or height.
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 you can’t always use it: a remote URL is a runtime string, not a build-time import. The moment src is a plain string, the component has no way to read the file ahead of time, so you have to supply what the import would have given it. Four props become required: src, width, height, and alt.
One misread catches almost everyone, so it’s worth clearing up now. width and height are not the display size. They give the image’s intrinsic aspect ratio: they tell the component the shape of the source so it can reserve a correctly-proportioned box. How big the image actually renders is a CSS decision, made with a className as always.
<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"/>That 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 only needs 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 image, such as a background flourish or a divider, pass alt="". The empty string is a deliberate signal, not a lazy shortcut: it tells screen readers “skip this, it adds nothing.” Writing genuinely good alt text is its own discipline. The rule that matters here is that the prop is never silently omitted.
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 whose size is set 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 instead:
<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 component 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, which is why this lesson states it here and again at the end: a fill image requires a sizes prop. Leave sizes off and the browser, having no idea how wide this image will render, plays it safe and downloads the largest variant in the set, which defeats the entire point of using the component. The next section unpacks sizes; for now, the reflex is that fill always travels with sizes. And when you do know fixed dimensions, prefer them: fill is only for when you genuinely can’t.
The props you’ve met 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. They’re also where beginners under-invest: they reach for next/image, skip these props, and wonder why the performance win didn’t materialize. Each one exists to fix a specific failure, so each is introduced here by the failure it prevents rather than as a line in a checklist.
sizes: the prop that makes responsive images actually work
Section titled “sizes: the prop that makes responsive images actually work”This is the highest-leverage and most-skipped prop in the whole component. next/image generates a srcset, a set of the same image at several widths, and lets the browser pick the right one. But the browser makes that pick before it has laid the page out, so it doesn’t yet know how wide your image will actually render. Without help, it assumes the worst: that the image might fill the whole viewport, and grabs the biggest candidate. That’s the oversized-bytes failure again, the exact thing the component was supposed to fix.
sizes is how you tell 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:
<Image src={photo.url} alt={photo.caption} fill sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"/>Read that string out loud: “at 1024px and up, this image takes a third of the viewport width; from 640px up, half; otherwise, the full width.” Each clause is a guess at the image’s rendered width that you derive from your own layout. A three-column grid means each cell is 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 here. You’re handing the browser the one number it can’t compute on its own, so it can fetch a file that fits instead of one five times too big.
The diagram below makes the mechanism concrete. The part people miss is that sizes is an input to the browser’s pick, not a CSS size.
one set the optimizer generated
the width you hand the browser
Phone
sizes 100vw → slot ≈360px
fetches 640wDesktop
sizes 33vw → slot ≈620px
fetches 1080wpreload: for the one image the user sees first
Section titled “preload: for the one image the user sees first”The hero image at the top of the page, the lead product photo above the fold: that’s your LCP element, the biggest thing the user sees before anything else paints. By default next/image lazy-loads. That’s right for the avatar three screens down, but wrong for the hero, where you want it loading as early as possible. preload={true} inserts a <link rel="preload"> into the document head, so the browser starts fetching the image before it even discovers the <Image> in the body. That directly improves LCP.
<Image src={hero.url} alt="Dashboard overview" width={1280} height={720} preload sizes="100vw"/>One naming correction matters here, because it trips you up when you read existing code. In Next.js 16 the prop is preload. Older codebases and most tutorials call it priority. The team renamed it so the name describes what it does: it inserts a preload link, rather than signalling a vague “this is important.” They’re the same idea, so if you see priority in someone else’s project, that’s the old name for preload. Write preload.
The discipline matters more than the syntax: preload exactly one image per page, the LCP candidate. Preloading everything is the same as preloading nothing. Each preload jumps the queue, so if you preload ten images you flood that queue and destroy the very signal you were trying to send. The browser 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 jump, but it stays an empty box until the bytes arrive. placeholder="blur" fills that gap with a tiny blurred preview of the image while the full version streams in, so the user sees something image-shaped immediately. Static imports get the blur for free, from the blurDataURL mentioned earlier. Remote sources don’t ship one, so you’d supply a generated blur or an inline base64 string yourself.
The blur is worth its bytes for a large hero or a media-heavy gallery, where the empty box would be glaring. It’s pure noise on a 32px avatar, so don’t blanket it across every image on the page. Reserve it for the images big enough that the blur actually 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, because 75 is a genuinely good balance and the human eye rarely notices the difference above it. But in Next.js 16, how you change it changed in a way that catches upgraders, so it’s worth getting exactly right.
Older versions let you pass any quality value freely. The new default for images.qualities is a single allowed value, [75], and to use any other quality you must first add it to that allowlist in 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. If you write quality={90} on an image but forget to add 90 to the array, the page doesn’t crash. The component quietly uses the closest allowed value instead: you asked for 90, the array only allows 75, so you get 75. The only thing that returns an outright 400 is hitting the optimizer’s raw endpoint with an unlisted quality, which you won’t do by hand. That silent rounding is easy to miss. The image renders, nothing errors, and you’re left wondering why your “high quality” hero looks exactly like the default. So when you set a non-default quality, the prop and the config array must agree.
Why does this allowlist exist at all? An unbounded quality range is an abuse vector: an attacker can request the same image at a thousand distinct quality values and explode your optimizer’s cache, since each variant is a separate cached object. Collapsing the default to a single value closes that door. It’s a security-and-cost default, not a stylistic one.
This is also a genuine “the platform changed under you” moment, worth flagging clearly: code that passed arbitrary quality values in Next.js 15 silently coerces to 75 in Next.js 16 until you widen the array. If you upgrade a project and your custom-quality images suddenly look softer, this is why.
The takeaway across all four props: leave quality at 75 unless you have a measured reason not to, and the moment you reach for any of these knobs, make the page and the config agree.
The next exercise checks the two trickiest of these props, the ones that come up constantly in review.
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.
<Image src={hero.url} alt="Product hero" width={1280} height={720} sizes={___} ___/>External images: the optimizer and remotePatterns as the security gate
Section titled “External images: the optimizer and remotePatterns as the security gate”That brings us to the second pass. A remote image, such as a product photo on S3 or an asset on a CDN, can’t be a static import, so it goes through a different machine entirely. To understand the security gate that’s coming, you first have to understand that machine: the optimizer.
The optimizer, briefly
Section titled “The optimizer, briefly”When src is a remote URL, the browser does not fetch that URL directly. Instead it requests an endpoint on your own app, /_next/image?url=...&w=...&q=.... That endpoint fetches the original image 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. This is what produces the srcset variants and the modern formats for remote images, exactly as the static path does for local ones, but on demand, at request time, through your server.
On Vercel this is the built-in Image Optimization: edge-cached, keyed by the URL plus width plus quality plus format, so a given variant is transcoded once and served from cache thereafter. One honest cost to name is that this optimization is metered on Vercel’s plan. It isn’t free compute, which is the first hint of why you don’t want to let arbitrary images through it.
The security problem, made concrete
Section titled “The security problem, made concrete”Look again at that endpoint: it takes a url parameter and fetches it on your server’s behalf. Now imagine you let it fetch any URL at all. An attacker writes a script that hits /_next/image?url=<some-enormous-file-hosted-anywhere>&w=3840 in a loop. Every request makes your server fetch a huge file, transcode it, and cache it, burning your compute, your bandwidth, and your optimizer bill. It also turns your domain into a free open image proxy for whatever they want to launder through it. That’s the failure remotePatterns exists to close off.
remotePatterns: the allowlist
Section titled “remotePatterns: the allowlist”In Next.js 16, any non-local image source requires a matching entry in images.remotePatterns. The optimizer refuses a URL that doesn’t match one: no entry, no fetch. Each entry describes an origin you trust, with up to five fields: protocol, hostname, port, pathname, and search. Write one entry per real origin, and make each as tight as you can: name the exact hostname, pin protocol: 'https', and narrow pathname to the prefix you actually serve from.
The cardinal rule reads most clearly as a before and after, so here are the two versions side by side.
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. It’s convenient in a five-minute tutorial and a genuine security hole in production. Never ship it.
const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'assets.acme-cdn.com', pathname: '/marketing/**' }, { protocol: 'https', hostname: 'uploads.acme-app.com', pathname: '/avatars/**' }, ], },};One entry per real origin, each as tight as you can make it. Exact hostnames, https pinned, pathname narrowed to the prefix you actually serve. The optimizer fetches from these two paths and refuses everything else.
Each field in the scoped version is doing real work. Hover them to see what each one constrains:
{ protocol: 'https', hostname: 'uploads.acme-app.com', pathname: '/avatars/**', search: '',}One sibling is worth naming before moving on. New in Next.js 16, a local image, one served from your own origin, that carries a query string in its src needs a matching images.localPatterns entry. This is the same allowlist idea applied to your own paths. Most projects never hit it, because plain /public paths have no query string. But if you ever serve a local image with ?something on the end and get a refusal, that’s the config you’re missing. It’s enough to know the key exists; you won’t need to reach for it often.
The decision that ties both passes together
Section titled “The decision that ties both passes together”Here’s the rule the whole lesson has been building toward, and it turns on provenance, meaning where the asset came from:
- Assets you control at build time, such as design-system logos, marketing graphics, and illustrations, go in
/publicor a static import. NoremotePatternsneeded; they’re already yours. - Assets fetched at runtime, anything a user uploaded or anything from a third party, use a remote
srcplus aremotePatternsentry for its origin. Never a wildcard host.
Provenance picks the path. Try making the call yourself before reading on.
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?
remotePatterns. Avatar: a remote src plus a remotePatterns entry for the S3 origin.src, with a single remotePatterns entry using hostname: '**' to cover both.src from your CDN; avatar as a static import once the user uploads it.src and a scoped remotePatterns entry — never a wildcard **, which re-opens the open-proxy hole. And you can’t statically import a file that doesn’t exist until a user uploads it.The optimizer pipeline and its limits
Section titled “The optimizer pipeline and its limits”You’ve got the security gate. Now round out the mental model of what the optimizer actually does, and just as importantly what it doesn’t, so you reach for the right tool when you outgrow it.
Format negotiation. The optimizer reads the browser’s Accept header and serves AVIF or WebP to browsers that support it, falling back to the original otherwise. images.formats controls the menu, and the default is ['image/webp']. AVIF compresses roughly 20% smaller than WebP, but it also encodes about 50% slower, which shows up as first-request latency on a cold cache. The pragmatic pick for most SaaS surfaces is WebP-only (the default). Add AVIF only when your asset library is large and bandwidth is genuinely the dominating cost. Don’t switch it on reflexively because “AVIF is smaller”: it’s a trade, not a free win.
deviceSizes and imageSizes. These govern the exact set of widths the optimizer generates for the srcset. The defaults already cover the breakpoints a normal app cares about. Learn the key names so you recognize them in someone’s config, but leave them alone until profiling shows a real gap. Tuning them before you have evidence is effort spent against a problem you don’t have.
What the optimizer does not do. Width, quality, and format are the only transforms. There’s no cropping, no overlays, no watermarks, no text, no smart-cropping to a focal point. The moment you need any of those, next/image is the wrong tool. You reach instead for a dedicated image service such as Cloudinary, Imgix, or Cloudflare Images, or you run sharp in a background job (which the course covers when it reaches background work). Knowing this boundary keeps you from expecting next/image to be a full image-processing pipeline: it’s a delivery optimizer, not an editor.
SVGs and the unoptimized escape hatch
Section titled “SVGs and the unoptimized escape hatch”There are two escape hatches, and one of them carries a security risk worth being precise about.
unoptimized bypasses the optimizer entirely and serves the original bytes untouched. It’s legitimate for an asset that’s already optimized, where re-encoding would just burn compute for no gain.
SVGs get this treatment automatically. You’ll read in old posts that “Next refuses SVGs,” but that’s wrong. Next serves any src ending in .svg as unoptimized by default. There are two reasons. SVGs are a vector format, so they resize losslessly and there’s nothing for the optimizer to transcode. They can also carry embedded script, which makes them an XSS vector. So the optimizer stays out of their way rather than running them.
To force SVGs through the optimizer you’d have to set images.dangerouslyAllowSVG: true, and the dangerously prefix is the warning. Doing it safely also requires pairing it with contentDispositionType: 'attachment' and a locked-down contentSecurityPolicy so an embedded script can’t execute. That combination exists; the full security-header story comes later in the course.
Off Vercel. The optimizer needs a sharp-capable function to run. If you deploy somewhere without one, you wire a loader (or loaderFile) that points next/image at an external image CDN instead. That’s the whole “what if I’m not on Vercel” answer for now; the full pattern comes much later in the course.
Worked example: three images on one product page
Section titled “Worked example: three images on one product page”Now put all three paths on one realistic page, the brand logo, the S3 product photo, and the customer avatar from the opening, so the decisions sit side by side. This is the artifact you’ll pattern-match against in real work, so it’s worth stepping through one image at a time.
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 a static import carries its dimensions for free and needs no config. It’s small and lives in the header rather than being the LCP element, so it gets no preload. This is the zero-config local path.
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 (so it needs a remotePatterns entry; see the config below), explicit width/height for the ratio, a sizes string derived from its two-thirds-width slot, and preload because this is the page’s above-the-fold hero. quality={90} is why the config 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. And because it’s fill, sizes="40px" is mandatory: without it the browser would fetch the largest variant for a thumbnail.
Two of those images are remote, and one asks for a non-default quality, so the page is only half the story. It requires a matching next.config.ts. That coupling is exactly what trips people up: the prop on the page and the entry in the config have to agree.
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;Read the dependency in both directions. The product photo’s host needs the first remotePatterns entry, the avatar’s host needs the second, and the product’s quality={90} needs 90 in the qualities array. Miss the remotePatterns entry and the optimizer refuses the image outright. Miss the qualities entry and, as before, it doesn’t error: it silently serves 75, and you’re left wondering why the hero looks soft.
The discipline, restated
Section titled “The discipline, restated”Step back and the whole lesson comes down to one frame: every image prop maps to a Core Web Vital. width and height, or fill, reserve the box, which kills CLS. preload front-loads the one image that is your LCP. sizes, quality, and format negotiation cut the bytes. next/image makes that discipline the default, so you don’t re-derive it on every page. That’s the entire reason it’s the 2026 default and a raw <img> isn’t.
If you carry one rule out of here, make it the provenance rule: 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 worth committing to memory, because they’re the mistakes that show up in review again and again:
sizeson everyfillimage. Without it, the browser fetches the biggest variant and the whole optimization unravels.preloadon exactly the LCP image, one per page and never more, or the signal drowns.- An explicit
hostnamefor every external origin, never'**', which hands an attacker your optimizer.
If you want the whole lesson narrated end to end, with each idea demonstrated live in a running app, this walkthrough covers the same ground.
External resources
Section titled “External resources”Every prop on next/image, including the full sizes and placeholder reference.
remotePatterns, qualities, formats, and the rest of the images config surface.
Google's own course module on sizing, modern formats, and srcset/sizes: the platform-agnostic theory behind every next/image default.
The canonical reference for srcset and sizes. Read it once and the sizes prop stops feeling like magic.