Skip to main content
Next.js · lesson 11 of 11
Next.js advanced 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

nextjs next-image image-optimization srcset sharp

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&amp;w=828&amp;q=75 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fphoto.1gnukq9vo2bav.png&amp;w=1920&amp;q=75 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fphoto.1gnukq9vo2bav.png&amp;w=1920&amp;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&amp;w=828&amp;q=75 1x, /_next/image?url=%2Fphoto.png&amp;w=1920&amp;q=75 2x" src="/_next/image?url=%2Fphoto.png&amp;w=1920&amp;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&amp;w=384&amp;q=75 384w, /_next/image?url=%2Fphoto.png&amp;w=640&amp;q=75 640w, /_next/image?url=%2Fphoto.png&amp;w=750&amp;q=75 750w, /_next/image?url=%2Fphoto.png&amp;w=828&amp;q=75 828w, /_next/image?url=%2Fphoto.png&amp;w=1080&amp;q=75 1080w, /_next/image?url=%2Fphoto.png&amp;w=1200&amp;q=75 1200w, /_next/image?url=%2Fphoto.png&amp;w=1920&amp;q=75 1920w, /_next/image?url=%2Fphoto.png&amp;w=2048&amp;q=75 2048w, /_next/image?url=%2Fphoto.png&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=%2Fphoto.png&amp;w=3840&amp;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.

Frequently Asked Questions

Why does next build accept an Image with a string src and no width or height, when getImgProps throws a missing-property error for exactly that case?
The throw never runs during next build. next/image's own ImageProps type marks both width and height as optional, so TypeScript raises nothing either way, and the getImgProps function that actually enforces them wraps that whole check in `if (process.env.NODE_ENV !== 'production')` — code that next build's own production bundle never executes. The check only fires once a next dev server renders the page and hits that line with NODE_ENV set to development; next build and next start both skip it entirely and serve the component without the width/height attributes Next.js would otherwise have added.
Why did the generated srcset change from two candidates labeled 1x/2x to nine candidates labeled by pixel width?
Without a sizes prop, next/image assumes the rendered image stays a fixed CSS size, so it only needs a 1x and a 2x candidate — the closest configured width at or above the image's own width, and at or above twice that. Adding a sizes prop tells Next.js the image's rendered size varies with the viewport, so it switches to a filtered responsive list instead, not one candidate per configured width: its getWidths function reads the smallest vw percentage out of sizes, multiplies it by the first deviceSizes entry, and keeps only the combined imageSizes+deviceSizes widths at or above that threshold — a narrower sizes value keeps more of the small end, a wider one keeps fewer.
Does next/image need the sharp package installed manually to optimize images?
No, not on a Linux server. next lists sharp in its own optionalDependencies, so a plain npm install already pulls it in on a compatible platform — nothing to add to a project's own package.json. If sharp is missing anyway (installed with npm install --omit=optional, say), the optimizer doesn't error; it silently serves the original file completely unprocessed, with no resize and no format conversion, and no warning in the server log.
Why did adding images.remotePatterns for a Docker-internal hostname still return the exact same 400 response as before it was configured?
remotePatterns and the local-IP guard are two independent checks on the same request. A hostname that resolves to a private/local IP address — true for any other container's name on a Docker network — is rejected by that second guard even once remotePatterns allows the hostname itself, unless images.dangerouslyAllowLocalIP is also set to true. Both checks return the identical "url" parameter is not allowed message, so the response alone doesn't say which one fired.
Can a page request /_next/image directly with any width or quality it wants?
No. /_next/image enforces its own allow-list independently of what any Image component in the app actually renders: the w query parameter must match a value from next.config.ts's combined deviceSizes and imageSizes arrays, and q must match a value from the images.qualities array (just [75] by default). A request for an unlisted width or quality gets a 400 naming the exact value it rejected, even if no component anywhere in the app ever asked for it.