#next/image
AddedA plain <img src="/hills.jpg"> makes every visitor download the same file: a 2400-pixel, 1.8 MB JPEG goes to a phone that shows it 375 pixels wide. It also gives the browser no idea how tall the image is until the file arrives, so the text below jumps when it does. next/image is a React component plus a small image server built into Next.js. The component writes an <img> with a srcset of URLs like /_next/image?url=%2Fhills.jpg&w=750&q=75; the browser picks one; the image server (the "optimiser") resizes the original to that width, re-encodes it as WebP or AVIF, caches the result and sends it.
A good mental model is a tailor with a rack of standard sizes. You hand over the original once (src) and describe how wide it will be worn (sizes); the component lists every standard size it can make (srcset); the browser, which knows the screen width and pixel density, asks for the one that fits; the tailor cuts it on the first request and keeps it on the rack for everyone after. width and height are not the size it is worn at: they are the proportions, used to reserve the right amount of space before the image arrives.
What next/image decides for you, and what it doesn't:
| Concern | What next/image does | What you still decide |
|---|---|---|
| Size | Generates srcset from deviceSizes and imageSizes | sizes: how wide it is on screen |
| Format | WebP (or AVIF if configured) from the Accept header | images.formats |
| Layout shift | width/height attributes (or fill) reserve the box | The real aspect ratio |
| Loading | loading="lazy" by default | preload or loading="eager" for the hero |
| Security | Only allowed widths, qualities and URLs | remotePatterns, localPatterns, qualities |
One page, three images, each sized a different way. public/hills.jpg is a 2400 × 1600 JPEG of 1,812,040 bytes (a test photo; any large photo behaves the same):
import Image from "next/image";
export default function Home() {
return (
<main>
{/* 1. The hero: full width, above the fold, the page's LCP element */}
<Image
src="/hills.jpg"
alt="Green hills under an orange sky at sunset"
width={2400}
height={1600}
sizes="100vw"
preload
style={{ width: "100%", height: "auto" }}
/>
{/* 2. A card: 420px wide, or the whole screen on phones narrower than that */}
<div style={{ maxWidth: 420 }}>
<Image
src="/hills.jpg"
alt="The same hills, as a card"
width={2400}
height={1600}
sizes="(max-width: 420px) 100vw, 420px"
style={{ width: "100%", height: "auto" }}
/>
</div>
{/* 3. A fixed-size thumbnail: always 240 × 160 CSS pixels */}
<Image src="/hills.jpg" alt="Thumbnail of the hills" width={240} height={160} />
</main>
);
}<link rel="preload" as="image" imageSrcSet="/_next/image?url=%2Fhills.jpg&w=640&q=75 640w, … 3840w" imageSizes="100vw"/>
<img alt="Green hills under an orange sky at sunset" width="2400" height="1600" decoding="async" data-nimg="1"
style="color:transparent;width:100%;height:auto" sizes="100vw"
srcSet="/_next/image?url=%2Fhills.jpg&w=640&q=75 640w, …w=750… 750w, …w=828… 828w, …w=1080… 1080w,
…w=1200… 1200w, …w=1920… 1920w, …w=2048… 2048w, …w=3840… 3840w"
src="/_next/image?url=%2Fhills.jpg&w=3840&q=75"/>
<img alt="The same hills, as a card" loading="lazy" width="2400" height="1600" decoding="async" data-nimg="1"
style="color:transparent;width:100%;height:auto" sizes="(max-width: 420px) 100vw, 420px"
srcSet="(the same eight widths, 640w … 3840w)" src="/_next/image?url=%2Fhills.jpg&w=3840&q=75"/>
<img alt="Thumbnail of the hills" loading="lazy" width="240" height="160" decoding="async" data-nimg="1"
style="color:transparent"
srcSet="/_next/image?url=%2Fhills.jpg&w=256&q=75 1x, /_next/image?url=%2Fhills.jpg&w=640&q=75 2x"
src="/_next/image?url=%2Fhills.jpg&w=640&q=75"/>
What headless Chrome downloaded (currentSrc of each image):
hero (sizes=100vw) card (≤420px) thumbnail (240×160)
1280×900, DPR 1 w=1920 (397,082 B) w=640 (5,278 B) w=256 (1,318 B)
375×812, DPR 2 w=750 (9,710 B) w=750 (same URL) w=640 (5,278 B)- The page is a Server Component, so all of this happens on the server at build time (the route is static):
<Image>turns its props into a plain<img>. There is no client JavaScript for these images; thesrcsetdoes the work in the browser. - The hero says
sizes="100vw", so Next.js lists everydeviceSizeswidth (640 … 3840) with awdescriptor. On the 1280-pixel desktop, Chrome needs 1280 device pixels and takes the next width up,1920; on the 375-pixel phone at DPR 2 it needs 750 and takes exactly750. Same HTML, 397 KB on one screen and 9.7 KB on the other, instead of 1.8 MB for both. preloaddid two things: the hero has noloading="lazy"attribute, and a<link rel="preload" as="image" imageSrcSet=… imageSizes="100vw">went into the<head>, so the browser starts the download before it has even parsed the<img>. That is what you want for the LCP image, and only for it.- The card says "full screen below 420px, otherwise 420px". The
srcsetis the same list (Next.js can't evaluate media queries; it only checks whethersizesusesvw), but the browser can: on the desktop 420 device pixels →w=640; on the phone 375 × 2 = 750 →w=750, the same URL as the hero, so it was downloaded once. It is lazy-loaded (loading="lazy"), the default. - The thumbnail has no
sizes, so Next.js treats it as fixed-size and writes1x/2xdescriptors: 240 rounds up to the256entry ofimageSizes, and 480 (for 2× screens) rounds up to640. The desktop got 256, the phone got 640. - Every
<img>keepswidthandheightattributes, which is how the browser reserves a 3:2 box before any bytes arrive. Withstyle={{ width: "100%", height: "auto" }}the rendered size is controlled by CSS (1280×853 on the desktop, 375×250 on the phone) while the ratio still comes from the attributes: that is the CLS protection.
Each w in those URLs is a separate request to the optimiser. Here is what it sent back for one of them, twice in a row (curl -s -D - -o out.webp -H "Accept: image/avif,image/webp,*/*" "localhost:4510/_next/image?url=%2Fhills.jpg&w=1080&q=75"):
HTTP/1.1 200 OK
Vary: Accept
Cache-Control: public, max-age=14400, must-revalidate
ETag: Rtm4wo8b43_9_XATXMEiuiz15KvS8w9gduR8tRRj390
Content-Type: image/webp
Content-Disposition: attachment; filename="hills.webp"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache: MISS
Content-Length: 44934
time_total=0.093s
HTTP/1.1 200 OK
(the same headers)
X-Nextjs-Cache: HIT
Content-Length: 44934
time_total=0.0017s
$ file out.webp
RIFF (little-endian) data, Web/P image, VP8 encoding, 1080x720- The first request found nothing in
.next/cache/images, so the server readpublic/hills.jpg, resized it to 1080 × 720 with sharp and encoded WebP: 44,934 bytes from a 1,812,040-byte original, in 93 ms.X-Nextjs-Cache: MISSsays the work was done now. - The result was written to disk (
.next/cache/images/<hash>/14400.<expiry>.<etag>….webp; the folder held one file per width and format afterwards). The second identical request was aHITin 1.7 ms: no resizing. Cache-Control: public, max-age=14400, must-revalidateis theminimumCacheTTLdefault of 4 hours (it was 60 seconds before Next.js 16). The rule ismax(minimumCacheTTL, the upstream file's max-age);public/files are served withmax-age=0, so 4 hours wins.Vary: Acceptmatters for CDNs: the same URL returns WebP to a browser that accepts it and JPEG to one that doesn't (sendingAccept: image/jpeg,*/*gotContent-Type: image/jpeg, 9,863 bytes atw=640), so a cache in front must key onAccepttoo.Content-Disposition: attachmentand theContent-Security-Policy … sandboxheader are defences: opening an optimised image directly downloads it instead of rendering it as a document (the default changed toattachmentin Next.js 15).- Sending the browser's
If-None-Matchwith that ETag got304 Not Modified, so repeat visits within a session cost no image bytes at all.
The parameters are an allowlist, not free input: the optimiser rejects anything it wasn't configured to make. Each of these was a real request to the same app:
url=%2Fhills.jpg&w=1000&q=75 400 "w" parameter (width) of 1000 is not allowed
url=%2Fhills.jpg&w=640&q=80 400 "q" parameter (quality) of 80 is not allowed
url=https%3A%2F%2Fexample.com%2Fcat.jpg&w=640&q=75
400 "url" parameter is not allowed
url=%2Fmissing.jpg&w=640&q=75 404 "url" parameter is valid but internal response is invalid
url=%2Fhills.jpg&w=3840&q=75 200 image/webp, 2400x1600, 885,270 bytes (never upscaled)w=1000isn't indeviceSizes(640, 750, 828, 1080, 1200, 1920, 2048, 3840) orimageSizes(32, 48, 64, 96, 128, 256, 384), so it is refused. Without this rule anyone could ask for every width from 1 to 4000 and make your server encode thousands of variants: a cheap denial-of-service and a storage bill.q=80fails because Next.js 16 allows onlyqualities: [75]by default; older versions accepted any quality. Aquality={80}prop on<Image>is coerced to the closest allowed value (the docs say development logs a warning), but a hand-written URL with an unlistedqgets a 400.- A remote URL is refused unless it matches
images.remotePatterns; local paths can be restricted the same way withimages.localPatterns. w=3840is allowed, but sharp never makes an image larger than its source, so the 3840 slot returned the 2400-pixel original re-encoded (885 KB). That is why the hero's fallbacksrc(w=3840) is the biggest variant: old browsers withoutsrcsetget the full-size image.
What it costs
The optimiser trades CPU and disk for bytes on the wire. Measured on this app: the first request for a width took 25–300 ms of server time (93 ms for 1080 wide, 300 ms for the 3840 slot), cached hits took 1–2 ms. Every distinct url × w × q × format is a separate file on disk. On Vercel it is metered usage on your plan; self-hosted it is your own CPU and disk, so many teams move it to a CDN image service with a custom loader (below), or set unoptimized for images that don't benefit (SVGs, tiny icons, animated GIFs).
Importing the file instead of passing a string gives Next.js the bytes at build time, so it fills in width, height and a tiny blurred placeholder for you, and fingerprints the URL. This version can't be built from this page (the checker can't carry a binary JPEG), so it was built in a copy of the app with the same hills.jpg next to the page:
import Image from "next/image";
import hills from "./hills.jpg"; // a 2400 × 1600 JPEG next to this file
export default function Home() {
return (
<main>
<Image
src={hills}
alt="Green hills under an orange sky at sunset"
placeholder="blur"
sizes="100vw"
preload
style={{ width: "100%", height: "auto" }}
/>
</main>
);
}.next/static/media/hills.0m9f-qzn0u6z1.jpg 1,812,040 bytes (the original, with a content hash)
<img … width="2400" height="1600" … style="color:transparent;width:100%;height:auto;background-size:cover;
background-position:50% 50%;background-repeat:no-repeat;
background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg … feGaussianBlur stdDeviation='20' …
href='data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/wAARCAAFAAgDAREA…'/%3E%3C/svg%3E")"
src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhills.0m9f-qzn0u6z1.jpg&w=3840&q=75"/>
curl -sI /_next/static/media/hills.0m9f-qzn0u6z1.jpg Cache-Control: public, max-age=31536000, immutable
curl -sD - /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhills… Cache-Control: public, max-age=315360000, immutable- The import is handled by the bundler: it copies the file to
/_next/static/media/with a hash in its name and gives you an object withsrc,width: 2400,height: 1600andblurDataURL. That is why nowidth/heightprops were needed. placeholder="blur"paints the tiny JPEG (the base64 header says 8 × 5 pixels) scaled up and blurred through an inline SVG filter, as a CSS background of the<img>itself, so something shaped like the photo shows while the real bytes download. Next.js removes the background once the image loads.- Because the URL contains a content hash, it can never point at different bytes, so both the original and the optimised variants are served with
immutable(the optimised ones for 10 years) instead of the 4-hour TTL ofpublic/images. Change the file and the hash, URL and cache all change with it. - Use static imports for images that ship with the code (marketing pages, illustrations). Use string paths for images that change without a deploy (
public/or a CMS), and accept the shorter TTL, which you can't invalidate except by changing the URL.
A remote src needs width and height (Next.js can't read the file at build time) or fill, and the host must be allowed. In this app a second server on 127.0.0.1:4611 stands in for a CDN holding the same JPEG:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
images: {
// AVIF first, WebP as the fallback, the original format if the browser takes neither
formats: ["image/avif", "image/webp"],
// Only this host, port and folder may be optimised; everything else gets a 400
remotePatterns: [new URL("http://127.0.0.1:4611/photos/**")],
// Only because the stand-in CDN runs on 127.0.0.1: private IPs are blocked by default (SSRF)
dangerouslyAllowLocalIP: true,
},
};
export default nextConfig;import Image from "next/image";
export default function Gallery() {
return (
<main style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{/* fill: the image takes the size of this positioned box */}
<div style={{ position: "relative", aspectRatio: "3 / 2" }}>
<Image
src="http://127.0.0.1:4611/photos/hills.jpg"
alt="Green hills under an orange sky at sunset"
fill
sizes="50vw"
style={{ objectFit: "cover" }}
/>
</div>
</main>
);
}Accept: image/avif,image/webp,*/* w=640 200 image/avif 3,898 bytes 68 ms (MISS)
Accept: image/webp,*/* w=640 200 image/webp 5,278 bytes 29 ms (MISS)
Accept: image/png,*/* w=640 200 image/jpeg 9,863 bytes 25 ms (MISS)
Accept: image/avif,*/* w=1920 200 image/avif 149,030 bytes 241 ms (MISS), then 1.2 ms (HIT)
Accept: image/webp,*/* w=1920 200 image/webp 397,082 bytes 169 ms (MISS), then 1.5 ms (HIT)
url=…%2Fprivate%2Fhills.jpg (outside /photos/**) 400 "url" parameter is not allowed
The same app built without dangerouslyAllowLocalIP:
url=…%2Fphotos%2Fhills.jpg 400 "url" parameter is not allowed
next start logged: ⨯ upstream image http://127.0.0.1:4611/photos/hills.jpg hostname resolved to private IP ["127.0.0.1"]
If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.filldrops thewidth/heightattributes and renders the<img>withposition:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0, so the parent must be positioned and must have a size; hereaspectRatio: "3 / 2"gives it one.objectFit: "cover"crops instead of stretching.- With
sizes="50vw"the srcset started at384w: whensizesuses a fraction of the viewport, Next.js also adds theimageSizesentries that a half-width image could need (with33vwin an earlier version of the page it added256wand384w). formats: ["image/avif", "image/webp"]makes the optimiser walk the list and use the first type theAcceptheader names. AVIF was 2.7× smaller than WebP at 1920 pixels (149 KB vs 397 KB) but took longer to encode on a miss (241 ms vs 169 ms); after the first request both are 1–2 ms hits. Each format is a separate cached file.remotePatternsmatched protocol, host, port and the/photos/**path. A URL in another folder of the same host was refused: the pattern is the security boundary, so make it as narrow as your bucket.- Without
dangerouslyAllowLocalIP, the same allowed URL failed: since Next.js 16 the optimiser refuses to fetch anything that resolves to a private IP, even if the hostname matches. That stops someone using your image server to probe your internal network (SSRF). Leave it off in production.
A loader is a function from { src, width, quality } to a URL. With loader: "custom" Next.js stops pointing at /_next/image and the CDN does the resizing:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
images: {
loader: "custom",
// relative to the project root; this project keeps its code in src/
loaderFile: "./src/lib/image-loader.ts",
},
};
export default nextConfig;"use client";
import type { ImageLoaderProps } from "next/image";
// Builds an imgix-style URL: the CDN resizes and re-encodes, Next.js never sees the bytes
export default function cdnLoader({ src, width, quality }: ImageLoaderProps) {
return `https://img.example-cdn.com${src}?w=${width}&q=${quality ?? 75}&auto=format`;
}import Image from "next/image";
export default function Home() {
return <Image src="/hills.jpg" alt="Green hills at sunset" width={240} height={160} />;
}<img alt="Green hills at sunset" loading="lazy" width="240" height="160" decoding="async" data-nimg="1"
style="color:transparent"
srcSet="https://img.example-cdn.com/hills.jpg?w=256&q=75&auto=format 1x,
https://img.example-cdn.com/hills.jpg?w=640&q=75&auto=format 2x"
src="https://img.example-cdn.com/hills.jpg?w=640&q=75&auto=format"/>- The component still chooses the widths (256 for 1×, 640 for 2×, as before) and still writes
width,heightandloading="lazy"; only the URLs come from your function. - The loader file starts with
"use client"because a function can't be passed from a Server Component to the client; marking the module lets Next.js use it on both sides. - Nothing reaches
/_next/image, soremotePatterns,qualitiesand the disk cache no longer apply: the CDN's own rules and caching do. The docs list ready-made loaders for Cloudinary, imgix, Cloudflare, Akamai, Contentful and others.
The props that changed between versions
priority→preload(Next.js 16).prioritystill exists but is deprecated. The docs also point out that in most casesloading="eager"orfetchPriority="high"(passed through to the<img>) is enough, and thatpreloadis wrong when different images are the LCP element at different screen sizes, because every preloaded image is downloaded.- Defaults tightened in 16:
qualities: [75],minimumCacheTTL4 hours (was 60 s),imageSizeswithout16,maximumRedirects: 3, private IPs blocked, localsrcwith a query string needslocalPatterns.search. next/legacy/imageis the pre-13 component (wrapper<span>,layout="responsive",objectFitprops); it is deprecated in 16. In Next.js 13 the oldnext/future/imagebecamenext/image,altbecame required and the wrapper element disappeared.onLoadingCompleteis deprecated since 14; useonLoad, which (like any function prop) only works in a Client Component.
The card in the main example: sizes="(max-width: 420px) 100vw, 420px", srcSet with 640w, 750w, 828w, … 3840w. Which w?
Show answer
w=750, observed in headless Chrome at 375 × 812, DPR 2.
- The browser evaluates
sizesfirst: the viewport is 375 CSS pixels, so(max-width: 420px)matches and the slot is100vw= 375 CSS pixels. - It multiplies by the device pixel ratio: 375 × 2 = 750 device pixels needed.
- It picks the smallest candidate that is at least that wide:
750w. (At 1280 × 900, DPR 1, the second condition applies: 420 device pixels →640w.) - Next.js never ran on the phone for this choice: the server wrote the list, the browser did the arithmetic. That is why a wrong
sizescan't be fixed by the optimiser; it just obeys the URL it's given.