Next.js · lesson 11 of 11
next/image and Automatic srcset
next/image infers width/height from a static import, reshapes srcset with sizes, and needs images.remotePatterns plus a local-IP guard for a remote host.
By SkillByExample · Updated
next/image’s Image component never ships the original file to the browser — it rewrites
every src into a request against /_next/image, which resizes and re-encodes the real bytes
on demand. The project setup reuses the Docker pattern from the first
lesson — the node:26.13 image, the nextjs-modules
volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.
A Deterministic Image, Generated Inside the Container
A binary file can’t be committed through a markdown code fence, so the project generates its
own test image: generate-image.mjs writes a real PNG by hand, chunk by chunk, with no image
library at all — just node:zlib’s deflateSync and a plain CRC32 implementation, so the same
script produces the exact same bytes every time it runs.
// generate-image.mjs — writes public/photo.png: an 800x600 RGB gradient, no dependencies
import { mkdirSync, writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
const width = 800;
const height = 600;
function crc32(buf) {
let crc = ~0;
for (const byte of buf) {
crc ^= byte;
for (let i = 0; i < 8; i++) {
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
}
return ~crc >>> 0;
}
function chunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([length, typeBuf, data, crcBuf]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: RGB
const raw = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y++) {
const rowStart = y * (width * 3 + 1);
raw[rowStart] = 0; // filter: none
for (let x = 0; x < width; x++) {
const o = rowStart + 1 + x * 3;
raw[o] = Math.floor((x / width) * 255);
raw[o + 1] = Math.floor((y / height) * 255);
raw[o + 2] = 120;
}
}
const idat = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const png = Buffer.concat([
signature,
chunk("IHDR", ihdr),
chunk("IDAT", idat),
chunk("IEND", Buffer.alloc(0)),
]);
mkdirSync("public", { recursive: true });
writeFileSync("public/photo.png", png);
console.log(`wrote public/photo.png: ${png.length} bytes, ${width}x${height}`);
The rest of the project matches the first lesson’s own
shape: package.json pins the dependencies, next.config.ts turns on Cache Components,
tsconfig.json configures TypeScript, and app/layout.tsx is the required root layout.
{
"name": "next-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "20.19.43",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
export const metadata = { title: "Next App" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
$ docker run --rm -v "$(pwd)":/root/next-app -w /root/next-app node:26.13 node generate-image.mjs
wrote public/photo.png: 105016 bytes, 800x600
A Static Import Infers width and height Automatically
app/page.tsx imports photo.png directly instead of passing a string path. No width or
height prop appears anywhere — both are typed as optional on next/image’s own ImageProps,
so nothing here is required by TypeScript at all:
// app/page.tsx — a static import; no width or height prop at all
import Image from "next/image";
import photo from "../public/photo.png";
export default function HomePage() {
return <Image src={photo} alt="A generated gradient, from a static import" />;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 28 packages in 23s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 65ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.4s
Running TypeScript ...
Finished TypeScript in 2.8s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
ae3166cfeb861f5f7dd5978515d86700559d7a593f9010c2b4eae959df4a4e04
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<img[^>]*>'
<img alt="A generated gradient, from a static import" loading="lazy" width="800" height="600" decoding="async" data-nimg="1" style="color:transparent" srcSet="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fphoto.1gnukq9vo2bav.png&w=828&q=75 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fphoto.1gnukq9vo2bav.png&w=1920&q=75 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fphoto.1gnukq9vo2bav.png&w=1920&q=75"/>
$ docker rm -f nextjs-image-1
nextjs-image-1
The build succeeds with no props at all — photo.1gnukq9vo2bav.png in the rendered src is
the content-hashed filename Next.js’s bundler gave the imported file, served from
/_next/static/media/, not public/. The width/height attributes on the <img> tag match
generate-image.mjs’s own 800/600 exactly: the bundler read them straight out of the PNG’s
own header at build time. srcSet carries two candidates, w=828 and w=1920, labeled 1x
and 2x — the closest configured widths at or above 800 and at or above 1600.
width and height Are Required Only Once next dev Renders the Page
Swap the static import for a plain string path into public/, and leave width and height
off entirely — the same omission the static import above got away with. Predict whether
next build’s own TypeScript check objects to either one being missing:
// app/page.tsx — a string src; no width or height prop, the same omission as the static import above
import Image from "next/image";
export default function HomePage() {
return <Image src="/photo.png" alt="A string src, no width or height" />;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 61ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.5s
Running TypeScript ...
Finished TypeScript in 2.5s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-dev -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next dev
fa2c123d497bf49430131107b2628329e9ccffcf1e3b36326cad0986a693e4d6
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:3100/
HTTP 500
$ docker logs nextjs-image-dev 2>&1 | grep -oE 'Error: Image with src.*property\.' | sort -u
Error: Image with src "/photo.png" is missing required "width" property.
$ docker rm -f nextjs-image-dev
nextjs-image-dev
next build succeeds without a single complaint, exactly like the static import — width and
height are both optional in next/image’s own type, so TypeScript never checks for either.
But the dev server answers HTTP 500, and docker logs names the real cause directly:
Error: Image with src "/photo.png" is missing required "width" property. That throw lives
inside next/image’s own getImgProps function, wrapped in
if (process.env.NODE_ENV !== 'production') — it only runs while next dev renders a page,
never during next build and never under next start.
Add width and height back, matching the numbers generate-image.mjs wrote into the file
itself, and the same page renders cleanly under every mode:
// app/page.tsx — a string src; width and height supplied explicitly
import Image from "next/image";
export default function HomePage() {
return (
<Image src="/photo.png" alt="The same gradient, from an explicit src" width={800} height={600} />
);
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 74ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.3s
Running TypeScript ...
Finished TypeScript in 2.4s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
ee6a87edbe3bd9f78ae69fe07bf2403342dfd6311ede813033400fab4eb213b0
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<img[^>]*>'
<img alt="The same gradient, from an explicit src" loading="lazy" width="800" height="600" decoding="async" data-nimg="1" style="color:transparent" srcSet="/_next/image?url=%2Fphoto.png&w=828&q=75 1x, /_next/image?url=%2Fphoto.png&w=1920&q=75 2x" src="/_next/image?url=%2Fphoto.png&w=1920&q=75"/>
$ docker rm -f nextjs-image-2
nextjs-image-2
Same two candidates, w=828 1x and w=1920 2x, same width="800" height="600" on the
<img> tag — but every url= inside srcSet now reads the literal %2Fphoto.png, the public
path exactly as written, instead of a hashed /_next/static/media/ filename. A string src
skips the bundler entirely; Next.js never touches the file until a browser actually requests
one of these URLs.
A sizes Prop Switches the srcset to the Full Responsive List
Add a sizes prop, telling Next.js the rendered width actually changes with the viewport
instead of staying fixed. Predict whether srcSet grows, shrinks, or keeps exactly the same two
candidates:
// app/page.tsx — sizes added; same src, width, and height as before
import Image from "next/image";
export default function HomePage() {
return (
<Image
src="/photo.png"
alt="The same gradient, with a sizes prop"
width={800}
height={600}
sizes="(max-width: 600px) 100vw, 50vw"
/>
);
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 87ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.2s
Running TypeScript ...
Finished TypeScript in 2.6s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3119f4fa60e8d454ea480f15c602be1c34f8a9342b1e5d2d57b9ec1de4229a74
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<img[^>]*>'
<img alt="The same gradient, with a sizes prop" loading="lazy" width="800" height="600" decoding="async" data-nimg="1" style="color:transparent" sizes="(max-width: 600px) 100vw, 50vw" srcSet="/_next/image?url=%2Fphoto.png&w=384&q=75 384w, /_next/image?url=%2Fphoto.png&w=640&q=75 640w, /_next/image?url=%2Fphoto.png&w=750&q=75 750w, /_next/image?url=%2Fphoto.png&w=828&q=75 828w, /_next/image?url=%2Fphoto.png&w=1080&q=75 1080w, /_next/image?url=%2Fphoto.png&w=1200&q=75 1200w, /_next/image?url=%2Fphoto.png&w=1920&q=75 1920w, /_next/image?url=%2Fphoto.png&w=2048&q=75 2048w, /_next/image?url=%2Fphoto.png&w=3840&q=75 3840w" src="/_next/image?url=%2Fphoto.png&w=3840&q=75"/>
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app node:26.13 node -e "const { imageConfigDefault } = require('./node_modules/next/dist/shared/lib/image-config.js'); console.log('imageSizes:', imageConfigDefault.imageSizes.join(', ')); console.log('deviceSizes:', imageConfigDefault.deviceSizes.join(', '));"
imageSizes: 32, 48, 64, 96, 128, 256, 384
deviceSizes: 640, 750, 828, 1080, 1200, 1920, 2048, 3840
srcSet now lists nine candidates, 384w through 3840w — not simply every width in
imageSizes and deviceSizes combined. Those two arrays hold 7 and 8 widths respectively, 15
widths total, and the nine shown are a filtered subset of them. next/image’s own getWidths
function reads the smallest vw percentage out of sizes (50vw here), multiplies it by the
first deviceSizes entry (640 × 0.5 = 320), and keeps only the widths from that combined
15-entry list at or above the result — 384 and up, exactly the nine shown; 32 through 256
are dropped for being narrower than 320. Without sizes, Next.js assumes a fixed on-page size
and only needs enough candidates for 1x/2x screens; with it, the browser itself picks
whichever candidate best matches the CSS width the sizes expression resolves to at render
time, so Next.js offers the filtered ladder instead.
Fetching the Real Optimized Bytes
The container from the previous section is still running. Request two of its candidate widths
directly, sending an Accept header a real browser would send, and look at what actually comes
back — not the <img> tag’s claims about it:
$ curl -s -H 'Accept: image/webp' -o out-384.bin -w 'w=384 HTTP %{http_code} content-type=%{content_type} bytes=%{size_download}\n' 'http://localhost:3100/_next/image?url=%2Fphoto.png&w=384&q=75'
w=384 HTTP 200 content-type=image/webp bytes=1284
$ curl -s -H 'Accept: image/webp' -o out-750.bin -w 'w=750 HTTP %{http_code} content-type=%{content_type} bytes=%{size_download}\n' 'http://localhost:3100/_next/image?url=%2Fphoto.png&w=750&q=75'
w=750 HTTP 200 content-type=image/webp bytes=3500
$ file out-384.bin out-750.bin
out-384.bin: RIFF (little-endian) data, Web/P image, VP8 encoding, 384x288, Scaling: [none]x[none], YUV color, decoders should clamp
out-750.bin: RIFF (little-endian) data, Web/P image, VP8 encoding, 750x563, Scaling: [none]x[none], YUV color, decoders should clamp
$ [ $(stat -c%s out-384.bin) -lt $(stat -c%s out-750.bin) ] && echo 'out-384.bin is smaller than out-750.bin'
out-384.bin is smaller than out-750.bin
$ docker rm -f nextjs-image-3
nextjs-image-3
$ rm -f out-384.bin out-750.bin
Both requests come back image/webp, not the image/png public/photo.png was written as —
the Accept header, not the file extension, decides the format, because next.config.ts never
restricted images.formats away from its own default, ["image/webp"]. file confirms each
one was genuinely resized, not just relabeled: w=384 decodes to a real 384-pixel-wide WebP,
w=750 to a real 750-pixel-wide one, and the narrower file is the smaller of the two — less
image data to encode produces fewer bytes, exactly the relationship a resize should produce.
sharp Installs Itself, and Its Absence Is Silent
Nothing in package.json mentions sharp — it’s the package Next.js actually uses to resize
and re-encode on a real server, confirmed straight out of the installed next package:
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app node:26.13 node -e "console.log('next optionalDependencies.sharp:', require('./node_modules/next/package.json').optionalDependencies.sharp)"
next optionalDependencies.sharp: ^0.35.5
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app node:26.13 node -e "console.log('sharp version:', require('./node_modules/sharp/package.json').version)"
sharp version: 0.35.5
sharp is listed in next’s own optionalDependencies, so a plain npm install already pulled
it onto this Linux container — nothing to add to this project’s package.json at all. Installing
with --omit=optional into a separate volume skips it, to see what the optimizer does without
it. /_next/image caches every resized response under .next/cache/images, inside this
project’s own bind-mounted directory rather than the node_modules volume being swapped here —
the same trap Caching with “use cache” describes for the
fetch cache — so this request asks for w=640, a width nothing earlier in this lesson has
requested yet, to guarantee a real miss instead of silently replaying an already-cached
image/webp response from the previous section:
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-image-nosharp:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund --omit=optional
added 21 packages in 18s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-image-nosharp:/root/next-app/node_modules -w /root/next-app node:26.13 sh -c "test -d node_modules/sharp && echo present || echo absent"
absent
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-image-nosharp:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
Downloading swc package @next/swc-linux-x64-gnu... to /root/.cache/next-swc
Downloading swc package @next/swc-linux-x64-musl... to /root/.cache/next-swc
✓ Running next.config.ts took 25.6s
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.9s
Running TypeScript ...
Finished TypeScript in 2.3s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-image-nosharp:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
14040c7906416cc6f7f83e33e4237366941cdc791aa2c95ca97a58ca07ddd5e6
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -H 'Accept: image/webp' -D - -o out-nosharp.bin 'http://localhost:3100/_next/image?url=%2Fphoto.png&w=640&q=75' | grep -E 'HTTP|Content-Type|Content-Length|X-Nextjs-Cache'
HTTP/1.1 200 OK
Content-Type: image/png
X-Nextjs-Cache: MISS
Content-Length: 105016
$ stat -c%s out-nosharp.bin public/photo.png
105016
105016
$ docker logs nextjs-image-4 2>&1 | grep -v 'Network:'
▲ Next.js 16.4.3
- Local: http://localhost:3000
✓ Ready in 176ms
✓ Running next.config.ts took 129ms
$ docker rm -f nextjs-image-4
nextjs-image-4
$ rm -f out-nosharp.bin
The build itself needed network access too: --omit=optional also drops the platform-specific
@next/swc-* binaries next lists as optional right alongside sharp, so next build had no
native binary already installed and downloaded one at build time instead — the two
Downloading swc package ... lines above.
Without sharp, /_next/image still answers 200, X-Nextjs-Cache: MISS — a genuine attempt,
not a cached leftover — but Content-Type stays image/png even though Accept: image/webp
was sent, and Content-Length matches public/photo.png’s own size byte for byte: no resize, no
re-encoding. docker logs confirms there’s no warning anywhere either — the full server log for
this request is just the ordinary startup banner, nothing naming sharp or the fallback at all.
(This container isn’t attached to a custom Docker network, so its own Network: banner line
would report whatever address the default bridge network happens to hand out — not fixed by
anything this lesson controls — and is filtered out above rather than shown as if it meant
something.) The optimizer silently serves the original file completely unprocessed the moment
sharp isn’t there to do the real work.
A Remote Image Needs images.remotePatterns — and More
A second container, nextjs-image-host, serves the same photo.png over plain HTTP on its own
Docker network — standing in for a remote CDN or a user-uploads bucket:
// image-host.js — stands in for a remote image host, like an upload bucket or a CDN
const http = require("http");
const fs = require("fs");
const photo = fs.readFileSync("public/photo.png");
http
.createServer((req, res) => {
if (req.url === "/photo.png") {
res.setHeader("Content-Type", "image/png");
res.end(photo);
return;
}
res.statusCode = 404;
res.end("not found");
})
.listen(4000, () => console.log("listening on 4000"));
app/page.tsx now points src at that container by its Docker network name. The network below
is created with a fixed --subnet, and nextjs-image-host is given a fixed --ip on it, so the
IP address this section’s commands report stays the same on every run instead of whatever address
Docker happens to hand out next. Predict whether the build itself fails, since nothing in
next.config.ts allows this hostname yet:
// app/page.tsx — a remote src; next.config.ts doesn't know this host yet
import Image from "next/image";
export default function HomePage() {
return (
<Image
src="http://nextjs-image-host:4000/photo.png"
alt="An image served from a second container"
width={800}
height={600}
/>
);
}
$ docker network create --subnet 172.30.99.0/24 nextjs-image-net
390192811c0730fee19f94491f4fd104f55aa0092087be4ee00b5077d40cf2d9
$ docker run -d --name nextjs-image-host --network nextjs-image-net --ip 172.30.99.10 -p 4100:4000 -v "$(pwd)":/root/next-app -w /root/next-app node:26.13 node image-host.js
15d29fa31a87dd2bad68675952c7cde5fa09a0618151c1160edf20d5686635f6
$ until curl -s -o /dev/null http://localhost:4100/photo.png; do sleep 0.3; done
$ curl -s -o /dev/null -w 'HTTP %{http_code} content-type=%{content_type} bytes=%{size_download}\n' http://localhost:4100/photo.png
HTTP 200 content-type=image/png bytes=105016
$ docker run --rm --network nextjs-image-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 84ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.3s
Running TypeScript ...
Finished TypeScript in 2.4s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-5 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
58433b4f859051f7eee87ed00e43cc419c2758642087372d775285f71acb58c6
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i 'http://localhost:3100/_next/image?url=http%3A%2F%2Fnextjs-image-host%3A4000%2Fphoto.png&w=828&q=75' | grep -E 'HTTP|^\"'
HTTP/1.1 400 Bad Request
"url" parameter is not allowed
$ docker rm -f nextjs-image-5
nextjs-image-5
The build still succeeds — building never fetches a remote image, only next start serving a
real request does. That request comes back 400, "url" parameter is not allowed: Next.js
checks every remote src against images.remotePatterns before it ever tries to fetch it, and
this host matched nothing. Add a pattern for it:
// next.config.ts — remotePatterns added for the image host, nothing else changed
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
images: {
remotePatterns: [
{
protocol: "http",
hostname: "nextjs-image-host",
port: "4000",
pathname: "/**",
},
],
},
};
export default nextConfig;
Predict whether this alone is enough — the hostname now matches a configured pattern exactly:
$ docker run --rm --network nextjs-image-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 81ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.4s
Running TypeScript ...
Finished TypeScript in 2.1s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-6 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
4a7adc45dce9e5f8c6bc20a91b620d7f20b6cc56829e214afe22daf860f40a99
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i 'http://localhost:3100/_next/image?url=http%3A%2F%2Fnextjs-image-host%3A4000%2Fphoto.png&w=828&q=75' | grep -E 'HTTP|^\"'
HTTP/1.1 400 Bad Request
"url" parameter is not allowed
$ docker logs nextjs-image-6 2>&1 | grep -oE 'hostname resolved to private IP.*continue\.'
hostname resolved to private IP ["172.30.99.10"] If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.
$ docker rm -f nextjs-image-6
nextjs-image-6
Still 400, the exact same "url" parameter is not allowed message in the response —
remotePatterns isn’t the only gate. docker logs on the server names the real reason the
response itself never prints: nextjs-image-host resolves to a private Docker network IP, and a
second, independent check rejects any request whose hostname resolves to a private or local
address unless images.dangerouslyAllowLocalIP says otherwise. Never set that flag for a host a
user can control, or one serving the public internet — it switches off exactly the check that
stops a server-side image request from being redirected at an internal address (SSRF). It’s safe
to add here only because nextjs-image-host is this lesson’s own container, not something a
visitor controls:
// next.config.ts — dangerouslyAllowLocalIP added alongside remotePatterns
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
images: {
dangerouslyAllowLocalIP: true,
remotePatterns: [
{
protocol: "http",
hostname: "nextjs-image-host",
port: "4000",
pathname: "/**",
},
],
},
};
export default nextConfig;
$ docker run --rm --network nextjs-image-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 104ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.2s
Running TypeScript ...
Finished TypeScript in 2.3s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-7 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
a6b4bfed9ecbf379834d898a92e4b6b3a52c122b2d9b74a92a784875fe8deb06
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -o /dev/null -w 'HTTP %{http_code} content-type=%{content_type} bytes=%{size_download}\n' 'http://localhost:3100/_next/image?url=http%3A%2F%2Fnextjs-image-host%3A4000%2Fphoto.png&w=828&q=75'
HTTP 200 content-type=image/png bytes=110294
$ docker rm -f nextjs-image-7
nextjs-image-7
$ docker rm -f nextjs-image-host
nextjs-image-host
$ docker network rm nextjs-image-net
nextjs-image-net
200 at last, a real image/png response — image/png rather than image/webp this time
because this curl sent no Accept header at all, so the optimizer has nothing naming a
supported alternate format to negotiate against images.formats and keeps the source format
instead. Two separate guards, remotePatterns for which hosts are allowed at all and
dangerouslyAllowLocalIP for whether a local/private address is one of them, both had to pass
before Next.js would fetch and optimize anything from nextjs-image-host.
Practice
Each task below builds on the files the main lesson above left in place — do them in order. The container from the previous task is already removed by the time the next one starts a new one of its own.
1. Add quality={40} to app/page.tsx’s remote <Image>. Confirm whether the generated
q= value in the rendered <img> tag actually becomes 40, or stays at the default.
Solution
// app/page.tsx — quality={40} added; next.config.ts still has no qualities array
import Image from "next/image";
export default function HomePage() {
return (
<Image
src="http://nextjs-image-host:4000/photo.png"
alt="An image served from a second container"
width={800}
height={600}
quality={40}
/>
);
}
$ docker network create --subnet 172.30.99.0/24 nextjs-image-net
f8870cfe680d919a5aa0f3363464db903c2e5daa133e6548807b8b7d895a9e31
$ docker run -d --name nextjs-image-host --network nextjs-image-net --ip 172.30.99.10 -v "$(pwd)":/root/next-app -w /root/next-app node:26.13 node image-host.js
75688ad5d4ecdf157f62339f7481a87e95b4aabf2e67d46e0f469dc7edf8c1e6
$ docker run --rm --network nextjs-image-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 87ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.7s
Running TypeScript ...
Finished TypeScript in 2.8s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-8 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
d44d228f6a7e3f2703373d531f44f400b5a9c7b1b748592253447ffe78418a4d
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -o 'q=[0-9]*'
q=75
q=75
q=75
$ docker rm -f nextjs-image-8
nextjs-image-8
q= stays at 75, not 40 — quality is silently clamped to the nearest value in
next.config.ts’s images.qualities array, which defaults to [75] and nothing else. Setting
quality={40} on the component has no effect at all until qualities is told 40 is allowed.
2. Add qualities: [40, 75] to next.config.ts’s images object, keeping
quality={40} on the <Image> from task 1. Confirm q=40 now actually appears.
Solution
// next.config.ts — qualities: [40, 75] added alongside the existing images config
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
images: {
dangerouslyAllowLocalIP: true,
qualities: [40, 75],
remotePatterns: [
{
protocol: "http",
hostname: "nextjs-image-host",
port: "4000",
pathname: "/**",
},
],
},
};
export default nextConfig;
$ docker run --rm --network nextjs-image-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 192ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 2.7s
Running TypeScript ...
Finished TypeScript in 2.5s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-image-9 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3b62ee9e69a1f7ebd4aaaa92f11b058906e53095a5b98cd358e7b03a3c8cc469
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -o 'q=[0-9]*'
q=40
q=40
q=40
$ docker rm -f nextjs-image-9
nextjs-image-9
q=40 now shows up in every srcSet entry — the quality prop was never ignored, it was just
clamped to the closest value qualities allowed, and [40, 75] includes 40 outright.
3. Starting a fresh server from exactly the files task 2’s solution left on disk, request
/_next/image directly for a width and a quality no <Image> component in this project ever
asked for: w=999 and q=99. Confirm each is rejected with its own 400, then remove every
container, network, and volume this lesson created.
Solution
No file changes — task 2’s own container (nextjs-image-9) was already removed at the end of
its capture, so this starts a new one, nextjs-image-10, from the files it left behind.
$ docker run -d --name nextjs-image-10 --network nextjs-image-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
f35be09cc1086a5daaa9ad43fb3a6add6c877eb8105e93668a5c7620f85bd432
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -w '\nHTTP %{http_code}\n' 'http://localhost:3100/_next/image?url=http%3A%2F%2Fnextjs-image-host%3A4000%2Fphoto.png&w=999&q=40'
"w" parameter (width) of 999 is not allowed
HTTP 400
$ curl -s -w '\nHTTP %{http_code}\n' 'http://localhost:3100/_next/image?url=http%3A%2F%2Fnextjs-image-host%3A4000%2Fphoto.png&w=828&q=99'
"q" parameter (quality) of 99 is not allowed
HTTP 400
$ docker rm -f nextjs-image-10
nextjs-image-10
$ docker rm -f nextjs-image-host
nextjs-image-host
$ docker network rm nextjs-image-net
nextjs-image-net
$ docker volume rm -f nextjs-modules nextjs-image-nosharp
nextjs-modules
nextjs-image-nosharp
Both are rejected outright, each with its own HTTP 400 naming the exact value it refused: the
width and the quality allow-lists apply to every request /_next/image receives, not only the
ones a component on the page actually generated — nothing stops a reader from typing an
arbitrary w/q directly into the URL, so Next.js checks every request against the same two
arrays regardless of where it came from. Every container this lesson started was already removed
by the docker rm -f at the end of its own section; only the shared image-host container, the
network it and every next start container joined, and the two node_modules volumes outlived
any single section, and the last four commands above clear those too.