cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Images and fonts under the hood

Read how Next.js 16 checks /_next/image requests, keys and stores resized photos, downloads Google fonts at build time and sizes their fallbacks.

By the end, you can
  • 01
    Follow an image requestSay which check refuses a bad url, w or q, when the optimizer fetches and resizes, and what each response header comes from.
  • 02
    Read the image cacheWork out a request's cache key, find its folder in .next/cache/images, and read the parts of the file's name.
  • 03
    Explain next/font's build workTell what the build downloads from Google, what -s and .p mean in a font file's name, and how the fallback's size-adjust is computed.

A photo's trip through /_next/image

Optimizing images showed next/image from the outside: an <img> whose files come from the image optimizer, the part of the Next.js server that answers at /_next/image, resizes a photo and keeps the result. Optimizing fonts showed next/font: a font downloaded while the app builds, with a fallback font sized to match. Inside Next.js 16.4.3, both are code you can read. The optimizer works when a request arrives; next/font works while next build runs.

Three words first. A hash is a short code worked out from some data: the same data always gives the same code, and different data almost always a different one. SHA-256 is the hash the optimizer uses. sharp is an npm package that resizes and converts images; the optimizer calls it for the actual work.

Here is the tea shop with a photo of green tea in public/teas. It's a real project, built with next build and run with next start. The recording asked /_next/image for photos seven times, the way a browser asks for the files of a srcset, then asked /image-cache, a Route Handler made only for this recording, what the optimizer had written to the disk:

The tea shop's photos, through the optimizerNext.js
app/image-cache/public/teas/
app/image-cache/route.ts
import { readdir, stat } from "node:fs/promises";import { join } from "node:path";import { connection } from "next/server"; const folder = join(process.cwd(), ".next", "cache", "images"); export async function GET() {  await connection();  const keys = (await readdir(folder).catch(() => [])).sort();  const entries = [];  for (const key of keys) {    const [name] = await readdir(join(folder, key));    const [maxAge, expireAt, etag, upstreamEtag, extension] = name.split(".", 5);    const file = await stat(join(folder, key, name));    const upstream = Buffer.from(upstreamEtag, "base64url").toString();    entries.push({      folder: key,      maxAge,      expireAt: `saved + ${Math.round((Number(expireAt) - file.mtimeMs) / 1000)} s`,      etag,      upstreamEtag: upstream.replace(/-[0-9a-f]+"$/, '-<date>"'),      extension,      bytes: file.size,    });  }  return Response.json(entries);}
Recorded from Next.js 16.4.3

GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75200

Sent with the request
accept image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Response headers
Vary Accept
Cache-Control public, max-age=14400, must-revalidate
ETag EZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0
Content-Type image/webp
Content-Disposition attachment; filename="green-tea.webp"
Content-Security-Policy script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache MISS

The body that arrived is an image, image/webp: 640 × 427 pixels, 3.9 kB

The Network tab opens on the first request: image/webp, 640 pixels wide, X-Nextjs-Cache: MISS. Pick the others. The same request again is a HIT. Without an Accept header the answer is a JPEG. w=3840 came back 1600 pixels wide. /teas/oolong.jpg came back as image/png. /logo.svg got 400. The last request, /image-cache, lists five folders on the disk.

In app/image-cache/route.ts, shown first, line 5 is the folder the optimizer writes to, and line 13 splits each file name into its parts. Reading .next from your own code is never a good idea: everything in it is this version's internals. Here it's a way to watch the optimizer, not a way to write an app.

A column of six steps, top to bottom: validateParams, getCacheKey, fetch the original, read the first bytes, sharp: resize, WebP, and write the cache file. Beside validateParams: a bad url, w or q gets 400 with a message. Beside getCacheKey: a key already in .next/cache/images is a HIT, sent from the disk. The last four steps only run when the cache needs a new entry: on a MISS, the entry is made and then sent; on STALE, the old file is sent and then remade. On a refresh, sharp is skipped when the original hasn't changed.validateParamsgetCacheKeyfetch the originalread the first bytessharp: resize, WebPwrite the cache file400 with a messageHIT: send the filealready on the diskwhen a new entry is neededMISS: made, then sentSTALE: sent, then remade
Every request is checked, then turned into a key. Only when the cache needs a new entry, on a miss or to refresh a stale one, does the optimizer fetch the photo, read its type, resize it with sharp and write the result; a refresh skips sharp when the original hasn't changed.
Real-life analogyA library's copy desk

A library's copy desk takes slips. The clerk first checks each slip against the rules on the wall: only these sizes, only these books. A good slip gets a ticket number made from what it asks for, and the clerk looks for a drawer with that number. If one is there and its label is still good, the copy goes out at once. If not, the clerk fetches the book, checks it really is a book, makes the copy and files it. Some books are bought from the publisher long before the library opens.

In real life: The request slip, checked against the desk's rules
In Next.js: validateParams: url, w and q against the images config
In real life: A ticket number made from what the slip asks for
In Next.js: The cache key: a hash of the address, width, quality and format
In real life: A drawer with that number, and a label that says until when
In Next.js: A folder in .next/cache/images, and a file named with its expiry
In real life: The clerk checks the book is really a book, then copies it
In Next.js: The first bytes say what the file is; sharp makes the copy
In real life: Books ordered from the publisher before the library opens
In Next.js: Fonts downloaded from Google during next build
The main idea

The optimizer checks every request, turns a good one into a key, and only does work when the cache needs a new entry: on a miss, or to refresh a stale one. Then it fetches the original, reads its type from its first bytes, resizes it with sharp (unless an unchanged original lets it reuse the old copy), and writes it to .next/cache/images. next/font does its work earlier: during the build, Turbopack downloads the font from Google, names each file with marks the server reads later, and computes the fallback's size-adjust from measurements Next.js ships.

Checking url, w and q

A request to /_next/image reaches handleNextImageRequest in next-server.ts (lines 995 to 1144). Before anything else, it checks the query:

Checking the request, then the key: next-server.ts, lines 1064 to 1077TypeScript
const paramsResult = ImageOptimizerCache.validateParams(
  req.originalRequest,
  parsedUrl.query,
  this.nextConfig,
  !!this.dev
)

if ('errorMessage' in paramsResult) {
  res.statusCode = 400
  res.body(paramsResult.errorMessage).send()
  return true
}

const cacheKey = ImageOptimizerCache.getCacheKey(paramsResult)

Lines 1 to 6 hand the query and the config to validateParams. Lines 8 to 12: if it answers with an errorMessage, the server sends 400 with that message as the whole body, and stops. Line 14 makes the cache key. Before line 1, two other cases are answered: a project with output: "export" gets 400 Bad Request, and one with a custom loader or unoptimized gets a 404 page (lines 1008 to 1062).

validateParams (image-optimizer.ts, lines 178 to 338) checks the three query values in order: url, then w, then q. The url first:

The url checks, shortened from image-optimizer.ts (lines 204 to 255)TypeScript
if (!url) {
  return { errorMessage: '"url" parameter is required' }
} else if (Array.isArray(url)) {
  return { errorMessage: '"url" parameter cannot be an array' }
}
// …
if (url.startsWith('/')) {
  href = url
  isAbsolute = false
  if (
    /\/_next\/image($|\/)/.test(
      decodeURIComponent(parseUrl(url)?.pathname ?? '')
    )
  ) {
    return {
      errorMessage: '"url" parameter cannot be recursive',
    }
  }
  if (!hasLocalMatch(localPatterns, url)) {
    return { errorMessage: '"url" parameter is not allowed' }
  }
} else {
  // …
  if (!['http:', 'https:'].includes(hrefParsed.protocol)) {
    return { errorMessage: '"url" parameter is invalid' }
  }

  if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
    return { errorMessage: '"url" parameter is not allowed' }
  }
}
  1. Lines 1 to 5: no url, or several, is refused. (Left out: an address longer than 3,072 characters, or one starting with //.)
  2. Lines 7 to 21: an address starting with / is on this site. Lines 10 to 18 refuse the optimizer's own address, so a request can't ask it to optimize itself. Lines 19 to 21 check localPatterns; with none set, every local path is allowed.
  3. Lines 22 to 31: anything else must parse as an address, use http or https (lines 24 to 26), and match one of remotePatterns (lines 28 to 30). The default list is empty, so no remote photo is allowed until you add one.

Then the width and the quality:

The width and quality checks: image-optimizer.ts, lines 286 to 318TypeScript
const sizes = [...(deviceSizes || []), ...(imageSizes || [])]

if (isDev) {
  sizes.push(BLUR_IMG_SIZE)
}

const isValidSize =
  sizes.includes(width) || (isDev && width <= BLUR_IMG_SIZE)

if (!isValidSize) {
  return {
    errorMessage: `"w" parameter (width) of ${width} is not allowed`,
  }
}

const quality = parseInt(q, 10)

if (isNaN(quality) || quality < 1 || quality > 100) {
  return {
    errorMessage:
      '"q" parameter (quality) must be an integer between 1 and 100',
  }
}

if (
  qualities &&
  !qualities.includes(quality) &&
  !(isDev && quality === BLUR_QUALITY)
) {
  return {
    errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
  }
}

Line 1 joins deviceSizes and imageSizes: 640 to 3840 and 32 to 384 by default (image-config.ts, lines 237 to 262). Lines 7 to 14 refuse a width that isn't in the list; lines 3 to 5 and the end of line 8 only matter in next dev, for the tiny blurred placeholder. Lines 16 to 23 refuse a quality outside 1 to 100, and lines 25 to 33 one that isn't in qualities, which is [75] by default since Next.js 16. The widths are a closed list on purpose: without it, anyone could make your server resize a photo to every width from 1 to 4000 and fill its disk with copies.

The last check isn't a refusal. It picks the format to send:

Which format to send: image-optimizer.ts, lines 103 to 106TypeScript
function getSupportedMimeType(options: string[], accept = ''): string {
  const mimeType = mediaType(accept, options)
  return accept.includes(mimeType) ? mimeType : ''
}

Line 2 asks mediaType, from the @hapi/accept package Next.js bundles, which of the allowed formats (image/webp by default) the Accept header prefers. Line 3 keeps it only if the header contains that exact text. Chrome's header names image/webp; no header at all gives "", "keep the photo's own type". Here are refused requests, recorded with localPatterns and remotePatterns set:

Requests validateParams refusesNext.js
app/public/teas/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */  cacheComponents: true,  partialPrefetching: true,  images: {    localPatterns: [{ pathname: "/teas/**", search: "" }],    remotePatterns: [new URL("https://images.teashop.example/teas/**")],  },}; export default nextConfig;
Recorded from Next.js 16.4.3

GET /_next/image?w=640&q=75400

Response headers

The body that arrived, as it arrived

"url" parameter is required

Lines 7 to 10 of next.config.ts allow local paths under /teas/ only, with no query, and remote photos from one host. Pick each request: every refusal is 400 with one of the messages above, word for word. /logo.svg is refused here by localPatterns, before anything is fetched. The last request is a good one, for comparison. Now build requests of your own:

Playground: what does the optimizer make of this request?
The request, and the key a good one getsText
GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8

Hashed: "4/teas/green-tea.jpg64075image/webp"
Key:    …
Folder: .next/cache/images/…/
Try it yourself
The checks pass: send image/webp

Working out the key… The recording asked exactly this: 200, image/webp, 640 × 427 pixels, X-Nextjs-Cache: MISS.

The checks are a small model written for this lesson of validateParams with the default images config, tested against every recorded answer. The key is the SHA-256 of the text shown, worked out by your browser, as getHash does on the server. Reset goes back to the first request.

Fetching the original, then sharp

When the cache needs a new entry, on a miss or to refresh a stale entry, the optimizer needs the original photo, the upstream image. next-server's imageOptimizer picks one of two ways to get it (next-server.ts, lines 814 to 832). A local address is asked for inside the server itself:

A local photo, asked for inside the server: image-optimizer.ts, lines 962 to 973TypeScript
const mocked = {
  req: new MockedRequest({
    url: href,
    method,
    headers: {},
    socket: _req.socket,
  }),
  res: new MockedResponse({ maximumResponseBody }),
}

await handleRequest(mocked.req, mocked.res, parseReqUrl(href))
await mocked.res.hasStreamed

Lines 1 to 9 make a pretend request and response in memory, and line 11 hands them to handleRequest, which is the router server's own handler (next-server.ts, lines 779 to 793). So /teas/green-tea.jpg is answered exactly as a browser's request for it would be, through the steps From next dev to a response followed, without leaving the process. A remote address is fetched over the network, with one more check first:

A remote photo: the address check, shortened from image-optimizer.ts (lines 800 to 828)TypeScript
if (!dangerouslyAllowLocalIP) {
  const literalFamily = isIP(hostname)
  let addresses: LookupAddress[]

  if (literalFamily > 0) {
    // A literal address is connected to as-is, without any DNS resolution
    addresses = [{ address: hostname, family: literalFamily }]
  } else {
    addresses = await lookupAsSocketWould(hostname)
    pinnedLookup = createPinnedLookup(hostname, addresses)
  }
  // …
  const privateIps = addresses
    .map((record) => record.address)
    .filter((ip) => isPrivateIp(ip))

  if (privateIps.length > 0) {
    Log.error(
      'upstream image',
      href,
      'hostname resolved to private IP',
      JSON.stringify(privateIps),
      'If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.'
    )
    throw new ImageError(400, '"url" parameter is not allowed')
  }

Line 1: unless you set dangerouslyAllowLocalIP. Lines 5 to 11 find the server's addresses with a DNS lookup, and line 10 makes the connection use exactly those, so a DNS answer that changes a moment later can't send it somewhere else. Lines 13 to 26 refuse a private address, like your own computer or your company's network, with 400; the images lesson recorded that log line. The rest of fetchExternalImage (lines 788 to 936) follows up to 3 redirects, checking each new address the same way, gives up after 7 seconds (504), and stops reading past 50 MB (413).

Now the optimizer has bytes. A file's name, or the server's Content-Type, can say anything, so it reads the bytes themselves. Most file formats begin with a fixed sequence of bytes, a signature:

Reading the first bytes: shortened from detect-content-type.ts (lines 30 to 55)TypeScript
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
  return JPEG
}
if (
  [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
    (b, i) => buffer[i] === b
  )
) {
  return PNG
}
// …
if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) {
  return SVG
}
if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) {
  return SVG
}

Lines 1 to 3: a JPEG begins with the bytes ff d8 ff. Lines 4 to 10: a PNG with 89 50 4e 47 and four more: a marker byte, the letters PNG, then line endings. Lines 12 to 17: text starting with <?xml or <svg is taken for an SVG. The shop's oolong.jpg is really a PNG with a JPEG's name, and the recording shows it: asked for without an Accept header, it came back as image/png, named oolong.png. Then the type decides what happens:

Before sharp: shortened from transform.ts (lines 204 to 266)TypeScript
const maxAge = Math.max(
  nextConfig.images.minimumCacheTTL,
  getMaxAge(imageUpstream.cacheControl)
)
const upstreamType = await detectContentType(upstreamBuffer)

if (
  !upstreamType ||
  !upstreamType.startsWith('image/') ||
  upstreamType.includes(',')
) {
  opts.logger?.error(
    "The requested resource isn't a valid image for",
    href,
    'received',
    upstreamType
  )
  throw new ImageError(400, "The requested resource isn't a valid image.")
}
if (
  upstreamType.startsWith('image/svg') &&
  !nextConfig.images.dangerouslyAllowSVG
) {
  opts.logger?.error(
    `The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
  )
  throw new ImageError(
    400,
    '"url" parameter is valid but image type is not allowed'
  )
}
// …
if (BYPASS_TYPES.includes(upstreamType)) {
  return {
    buffer: upstreamBuffer,
    contentType: upstreamType,
    maxAge,
    etag: upstreamEtag,
    upstreamEtag,
  }
}

let contentType: string

if (mimeType) {
  contentType = mimeType
} else if (upstreamType === WEBP || upstreamType === AVIF) {
  // Downlevel WebP and AVIF when the client does not advertise support.
  contentType = JPEG
} else {
  contentType = upstreamType
}
  1. Lines 1 to 4: how long the result may be kept, maxAge, is the larger of minimumCacheTTL (4 hours by default) and the original's own max-age.
  2. Lines 7 to 19: something that isn't an image is refused, with The requested resource isn't a valid image.
  3. Lines 20 to 31: an SVG is refused unless dangerouslyAllowSVG is on. An SVG is a text file that can hold scripts, and resizing it gains nothing.
  4. Lines 33 to 41: some types are sent back as they are, never resized (BYPASS_TYPES: SVG, ICO, ICNS, BMP, JPEG XL and HEIC). Left out at line 32: an animated GIF, PNG or WebP is sent as it is too.
  5. Lines 43 to 52: the format to write. The one the Accept header allowed; else a WebP or AVIF original becomes a JPEG, for an old browser; else the original's own type, like the oolong PNG.

Here is the SVG both ways:

Playground: an SVG through the optimizer
The tea shop's logo, asked for at /_next/imageNext.js
app/public/teas/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */  cacheComponents: true,  partialPrefetching: true,}; export default nextConfig;
Recorded from Next.js 16.4.3

GET /_next/image?url=%2Flogo.svg&w=640&q=75400

Sent with the request
accept image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Response headers

The body that arrived, as it arrived

"url" parameter is valid but image type is not allowed
Try it yourself
dangerouslyAllowSVG off (the default)

The url, w and q pass the checks, so the optimizer fetches /logo.svg. Its first bytes are <svg, the type is image/svg+xml, and the answer is 400: "url" parameter is valid but image type is not allowed. The Terminal tab shows the error next start printed.

Both versions were built with next build from Next.js 16.4.3 and asked for /_next/image?url=%2Flogo.svg&w=640&q=75. The Network tab opens on that answer. Reset goes back to the default.

The allowed SVG was safe to open only because two of 16.4.3's defaults stayed in place: contentDispositionType: "attachment", so opening its address downloads the file instead of showing it, and contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;", so nothing in it may run (image-config.ts, lines 254 to 256). If you turn on dangerouslyAllowSVG, keep both. Never set "inline" or a weaker policy for SVGs you didn't make yourself: an SVG shown on its own can run the scripts inside it.

Last, sharp. getSharp (transform.ts, lines 88 to 130) loads it once with require('sharp'), allows only the image formats it needs, and halves the number of threads sharp may use, to use less memory. In 16.4.3 there's no other image library to fall back on: Next.js 15 removed the old squoosh one, so without sharp the optimizer throws Module `sharp` not found. Please run `npm install --cpu=wasm32 sharp` to install it.

sharp at work: transform.ts, lines 157 to 191TypeScript
const sharp = getSharp(concurrency, operationCache)
const transformer = sharp(buffer, {
  limitInputPixels,
  sequentialRead: sequentialRead ?? undefined,
})
  .timeout({
    seconds: timeoutInSeconds ?? 7,
  })
  .rotate()

if (height) {
  transformer.resize(width, height)
} else {
  transformer.resize(width, undefined, {
    withoutEnlargement: true,
  })
}

if (contentType === AVIF) {
  transformer.avif({
    // Scale the quality to try and match webp. This ratio was derived
    // from sharp's default 80 (webp) and 50 (avif), and then verified
    // using dssim and ssimulacra2 visual quality tests.
    quality: Math.max(Math.round(quality * (50 / 80)), 1),
    effort: 3,
  })
} else if (contentType === WEBP) {
  transformer.webp({ quality })
} else if (contentType === PNG) {
  transformer.png({ quality })
} else if (contentType === JPEG) {
  transformer.jpeg({ quality, mozjpeg })
}

const optimizedBuffer = await transformer.toBuffer()

Lines 2 to 9 open the image, give up after 7 seconds, and turn it upright from its camera data (rotate() with no angle). Lines 11 to 17 resize it; a /_next/image request has no height, so line 14 runs, with withoutEnlargement: true: never bigger than the original. The recorded w=3840 answer is 1600 by 1067, the photo's own size. Lines 19 to 26 lower the quality for AVIF, whose numbers mean something else than WebP's; lines 27 to 33 pass the quality to the other formats. Line 35 gets the bytes.

Cache keys and .next/cache/images

Back to line 14 of the first excerpt. The key is made like this:

The cache key: image-optimizer.ts, lines 340 to 352TypeScript
static getCacheKey({
  href,
  width,
  quality,
  mimeType,
}: {
  href: string
  width: number
  quality: number
  mimeType: string
}): string {
  return getHash([CACHE_VERSION, href, width, quality, mimeType])
}

Line 12 hashes five values with getHash: CACHE_VERSION, which is 4 (line 65), the address, the width, the quality and the format. getHash feeds them one after another into one SHA-256 and writes the result as base64url, letters, digits, - and _, safe in a folder name (extract-etag.ts, lines 3 to 13). With the version in the key, a release that changes how entries are stored can change it and get new keys, instead of misreading old files.

Five cards in a row: 4, /teas/green-tea.jpg, 640, 75 and image/webp. Arrows join them into getHash, SHA-256 as base64url, which gives the folder name 7M2d0nGe… inside .next/cache/images. The file in that folder is named maxAge.expireAt.etag.upstreamEtag.webp.4/teas/green-tea.jpg64075image/webpSHA-256, as base64urlcache/images7M2d0nGeHsnUsXrk…/maxAge.expireAt.etag.upstreamEtag.webp
The key is a hash of five values: the cache's version, the photo's address, the width, the quality and the format. The folder is named after the key; the file's name holds what the optimizer needs to read the entry back.

The key holds what the request asks for, and nothing about who asks: not the cookies, not the visitor. Two requests that differ only in their Accept header get two keys, because mimeType differs. The original's bytes aren't in it either: a new photo at the same address keeps its key, and replaces the old copy only once that copy is stale. A new entry is written like this:

Writing an entry: image-optimizer.ts, lines 108 to 134TypeScript
async function writeToCacheDir(
  cacheDir: string,
  cacheKey: string,
  extension: string,
  maxAge: number,
  expireAt: number,
  buffer: Buffer,
  etag: string,
  upstreamEtag: string
) {
  if (buffer.byteLength === 0) {
    throw new Error(
      'Invariant: cannot write an empty buffer to the image cache'
    )
  }

  const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
  const filename = join(
    /* turbopackIgnore: true */
    dir,
    `${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`
  )

  await promises.rm(dir, { recursive: true, force: true }).catch(() => {})

  await promises.mkdir(dir, { recursive: true })
  await promises.writeFile(filename, buffer)

Line 17: one folder per key. Lines 18 to 22: one file in it, whose name holds five parts joined by dots. Line 24 empties the folder first, so it never holds two files; line 26 makes it again, and line 27 writes the image's bytes into the file. The probe in the overview read that folder after the recording's requests:

Recorded: what /image-cache listed after the requestsJSON
[
  {
    "folder": "23QgcPR3itmiboMFr_pB2MCxpJnN0c3S6jcr5HAAWK0",
    "maxAge": "14400",
    "expireAt": "saved + 14400 s",
    "etag": "Jqig1gGJ8SGTKpZwvSqtTaCDkDt5Gl5azyU5_BihFE0",
    "upstreamEtag": "W/\"166e7-<date>\"",
    "extension": "webp",
    "bytes": 20264
  },
  {
    "folder": "7M2d0nGeHsnUsXrkpYgSU9FhOsK3F74Eiv2HRUBboMc",
    "maxAge": "14400",
    "expireAt": "saved + 14400 s",
    "etag": "EZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0",
    "upstreamEtag": "W/\"166e7-<date>\"",
    "extension": "webp",
    "bytes": 3942
  },
  {
    "folder": "DThO3IpWWi0pOMe52JuWvJevJMsOyFnlxlZgx-4OqHM",
    "maxAge": "14400",
    "expireAt": "saved + 14400 s",
    "etag": "rHLZoqrsyf13NVcHcqAIl1lSf5xQs_yFDIXl5kYAzBw",
    "upstreamEtag": "W/\"166e7-<date>\"",
    "extension": "jpeg",
    "bytes": 7178
  },
  {
    "folder": "gyx9B3WhhcDpHjZhmHTHxO7236AgQ74zzvpATuXdsc4",
    "maxAge": "14400",
    "expireAt": "saved + 14400 s",
    "etag": "_vQUAv93T3vehrMbDEBHpd8smaJfNvOTNUJQkLGx3xg",
    "upstreamEtag": "W/\"1f6f2-<date>\"",
    "extension": "png",
    "bytes": 17257
  },
  {
    "folder": "urUNOS3TOP9o2HQWTepWrmqDIGcLMhWT2JYwpQCqayE",
    "maxAge": "14400",
    "expireAt": "saved + 14400 s",
    "etag": "2J6IdLYokawUnnW4JDRlburzXh74MP7pjCzSAIJH_zk",
    "upstreamEtag": "W/\"166e7-<date>\"",
    "extension": "webp",
    "bytes": 5496
  }
]

Five folders for six good requests: the again request found its key. The folder 7M2d0nGe… is the first request's, the WebP 640 pixels wide; the JPEG of the same photo and width is DThO3IpW…. The parts of each name:

  • maxAge, 14400: four hours, from lines 1 to 4 of the transform excerpt.
  • expireAt: the time, in milliseconds, after which the entry is old: the moment it was saved plus maxAge (lines 507 to 509). The probe shows the difference, because the time itself changes on every run.
  • etag: the SHA-256 of the file written. It's also the answer's ETag header: the first request's was EZK2arlVzqj1ThoigZJXuLMi1O5l_FxhMfo5ByNc9h0, the same as the etag of folder 7M2d0nGe….
  • upstreamEtag: the original's own ETag, in base64url. For a file in public, that's a weak ETag the static file server makes from the file's size and date: W/"166e7-…", and 166e7 in hexadecimal is 91,879, the size in bytes of green-tea.jpg. It's what lets a stale entry's refresh skip sharp when the original hasn't changed.
  • extension: webp, jpeg or png, so the server can send the right Content-Type without reading the file.

Reading an entry back is get (lines 383 to 456): it splits the name the same way, and marks the entry stale when the time is past expireAt (line 450). The server's response cache, the same kind of cache that serves prerendered pages (Caching under the hood), decides what to send:

Asking the cache, and saying where the answer came from: shortened from next-server.ts (lines 1082 to 1133)TypeScript
const cacheEntry = await this.imageResponseCache.get(
  cacheKey,
  async ({ previousCacheEntry }) => {
    const { buffer, contentType, maxAge, upstreamEtag, etag } =
      await this.imageOptimizer(
        req,
        res,
        paramsResult,
        previousCacheEntry
      )
// …
sendResponse(
  req.originalRequest,
  res.originalResponse,
  paramsResult.href,
  cacheEntry.value.extension,
  cacheEntry.value.buffer,
  cacheEntry.value.etag,
  paramsResult.isStatic,
  cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT',
  imagesConfig,
  cacheEntry.cacheControl?.revalidate || 0,
  Boolean(this.dev)
)

Lines 1 to 10: imageResponseCache.get takes the key and a function that makes the entry, which calls the optimizer. It only calls that function on a miss, or, for a stale entry, after it has already sent the old file (response-cache/index.ts, lines 389 to 401): the new entry is made in the background, and a later request gets it once that has finished. Many requests for the same key at the same moment share one call. Line 20 turns what happened into X-Nextjs-Cache: MISS, STALE or HIT.

A miss and a stale refresh both make a new entry, but not quite the same way. On a miss, there's nothing to reuse: the optimizer fetches the original, reads its type, calls sharp and writes the file. On a stale refresh, the old entry is handed to the function as previousCacheEntry (line 3). The optimizer still fetches the original and reads its first bytes; then, if the original's ETag equals the upstreamEtag stored in the old file's name, and the old file was really resized, it takes the old bytes (image-optimizer.ts, lines 1081 to 1096, and get-previously-cached-image-or-null.ts, lines 7 to 22) and returns them before sharp is called (transform.ts, lines 268 to 276). sharp only runs on a refresh when the old output can't be reused: the original's ETag changed, or the old file was the original itself, sent unresized because sharp failed on it. Either way, the refreshed entry is written again, with a new expireAt (response-cache/index.ts, lines 549 to 554). The recording has no stale entry: making one means waiting four hours, so this part is read from the source. The answer's other headers are set here:

The answer's headers: shortened from image-optimizer.ts (lines 1133 to 1155)TypeScript
res.setHeader('Vary', 'Accept')
res.setHeader(
  'Cache-Control',
  isStatic
    ? 'public, max-age=315360000, immutable'
    : `public, max-age=${isDev ? 0 : maxAge}, must-revalidate`
)
if (sendEtagResponse(req, res, etag)) {
  // already called res.end() so we're finished
  return { finished: true }
}
if (contentType) {
  res.setHeader('Content-Type', contentType)
}
// …
const fileName = getFileNameWithExtension(url, contentType)
res.setHeader(
  'Content-Disposition',
  contentDisposition(fileName, { type: imagesConfig.contentDispositionType })
)

res.setHeader('Content-Security-Policy', imagesConfig.contentSecurityPolicy)
res.setHeader('X-Nextjs-Cache', xCache)

Each row is one header from the recording, and the line of this excerpt that set it:

Where each optimizer header comes from
HeaderSet byWhat it means
Vary: AcceptLine 1, on every answer.The answer depends on the Accept header, so a cache in between keeps one copy per Accept.
Cache-Control: public, max-age=14400, must-revalidateLines 2 to 7; 14400 is the larger of minimumCacheTTL and the original's own max-age.A browser may keep it 4 hours. A static import's photo gets immutable and ten years instead.
ETag: EZK2arlV…Line 8, through sendEtagResponse.The SHA-256 of the file sent. A request whose If-None-Match has it gets 304 and no body.
Content-Type: image/webpLines 12 to 14.The format sharp wrote, from mimeType, or the original's own type.
Content-Disposition: attachment; filename="green-tea.webp"Lines 16 to 20.Opened on its own, the address downloads a file, named after the original with the new extension.
Content-Security-Policy and X-Nextjs-CacheLines 22 and 23.No script or frame may run in it; and whether the file came from the disk (HIT), was made now (MISS) or was old (STALE).

The folder doesn't grow forever. The disk cache keeps a list of its entries, least recently used last, and deletes from the end when the total passes images.maximumDiskCacheSize; left unset, that's half of the free disk space when the server starts (disk-lru-cache.external.ts, lines 28 to 36). 0 turns the disk cache off. With images.customCacheHandler: true, the entries go to your cacheHandler instead, one store for every instance, as Self-hosting Next.js set up.

Internals, not an API

The cache's folder, its key's inputs, the file name's parts, the font file marks and the manifests are Next.js 16.4.3's internals, from these recorded builds. They can change in any release, and CACHE_VERSION exists for that. What the docs promise is the config: deviceSizes, imageSizes, qualities, formats, the patterns, minimumCacheTTL, maximumDiskCacheSize, and next/font's options. Never read or write .next/cache from your app's code.

Downloading fonts at build time

Now the fonts. Lora({ subsets: ["latin"] }) looks like a function call, but SWC, the compiler (SWC and Turbopack), turns it into an import of a special address with your options written into it, like next/font/google/target.css?{"path": …, "import": "Lora", …} (google/request.rs, lines 4 to 6). With Turbopack, the default bundler, that import is answered by Rust code in Next.js's crates/next-core/src/next_font/google folder, compiled into the @next/swc package that next installs. It makes two modules that exist only in memory: a JavaScript one that exports className and style, and a CSS module with the @font-face rules (google/mod.rs, lines 196 to 305). For the CSS, it asks Google:

Where and as whom Turbopack asks: google/mod.rs, lines 67 to 74Rust
pub const GOOGLE_FONTS_STYLESHEET_URL: &str = "https://fonts.googleapis.com/css2";
// Always sending this user agent ensures consistent results from Google Fonts.
// Google Fonts will vary responses based on user agent, e.g. only returning
// references to certain font types for certain browsers.
pub const USER_AGENT_FOR_GOOGLE_FONTS: RcStr = rcstr!(
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) \
     Chrome/104.0.0.0 Safari/537.36"
);

Line 1 is Google Fonts' CSS address. Lines 5 to 8 are the user agent, the header that says which browser is asking: always Chrome 104, because Google sends different CSS to different browsers, and Chrome's gets WOFF2 files. get_stylesheet_url adds the family, the weights and display (google/util.rs, lines 149 to 287). The recording proves both the address and the timing with a build that can't reach Google. Pick the last version:

Playground: three fonts, three builds
The tea shop's root layout, with this fontNext.js
app/
app/layout.tsx
import { Lora } from "next/font/google";import "./globals.css"; const font = Lora({  subsets: ["latin"],}); export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body className={font.className}>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○  (Static)  prerendered as static content # What .next/static/media holds: 7 files, 158.2 kB (deeper folders added up).next/static/media/02ee680d6f7b72e4-s.2936i88_6qsfd.woff2  17.3 kB.next/static/media/1854c6960aa5e9fd-s.2qdkzeru_ecot.woff2  20.2 kB.next/static/media/2c6bb81ef2959eb3-s.2fpqrm51ez0iq.woff2  21.3 kB.next/static/media/80aaaae4466959af-s.15gdzqknx46iu.woff2  8.9 kB.next/static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2  37.8 kB.next/static/media/c713a0aab7a8ca36-s.0mybutugvu-lq.woff2  23.6 kB.next/static/media/e929b158eb81aae9-s.02frcczqg7k-8.woff2  29.2 kB
Try it yourself
Lora

Seven files in .next/static/media, one per subset Google has for Lora. Every name has -s; only the latin file has .p, and it's the one the page's Link header preloads.

Every version was built with next build from Next.js 16.4.3. The Terminal tab shows the build and what it wrote to .next/static/media; the Network tab shows the page's preload Link header. Reset goes back to Lora.

In Lora, with Google out of reach, the build's connections must go through a proxy address that never connects them anywhere. The Terminal tab shows: There was an issue establishing a connection while requesting https://fonts.googleapis.com/css2?family=Lora:wght@400..700&display=swap, then Failed to fetch Lora from Google Fonts. next build stops (next_font/issue.rs, lines 56 to 107; next dev only warns and uses the fallback font). The module names in the trace, lora_a52a04ae.js and lora_a52a04ae.module.css, are the two in-memory modules, named after the font and a hash of your options. Turbopack expects Google's CSS to give each subset its own @font-face, after a comment naming it, like /* latin */, and reads it line by line:

Finding the font files in Google's CSS: shortened from google/mod.rs (lines 512 to 563)Rust
fn find_font_files_in_css(css: &str, subsets_to_preload: &[RcStr]) -> Vec<FontFile> {
    let mut font_files: Vec<FontFile> = Vec::new();
    let mut current_subset = "";

    for line in css.lines() {
        static COMMENT_RE: LazyLock<Regex> =
            LazyLock::new(|| Regex::new(r#"/\* (.+?) \*/"#).unwrap());
        if let Some(new_subset) = COMMENT_RE.captures(line).and_then(|c| c.get(1)) {
            current_subset = new_subset.as_str();
            continue;
        }

        static FONT_URL_RE: LazyLock<Regex> =
            LazyLock::new(|| Regex::new(r#"src: url\((.+?)\)"#).unwrap());
        let Some(font_url) = FONT_URL_RE.captures(line).and_then(|c| c.get(1)) else {
            continue;
        };
        let font_url = font_url.as_str();
        // …
        font_files.push(FontFile {
            font_url: font_url.to_string(),
            ext: ext.to_string(),
            preload: subsets_to_preload
                .iter()
                .any(|subset| subset == current_subset),
        });

Lines 6 to 11: a comment like /* latin */ sets the current subset. Lines 13 to 18 take each src: url(…), a font file's address. Lines 20 to 26 keep the file, marked to preload when its subset is one you listed in subsets. Each address in the CSS is then replaced by an import of the file, and each file is downloaded and named:

Naming a downloaded file: google/mod.rs, lines 403 to 410Rust
// remove dashes and dots as they might be used for the markers below.
let mut name = format!("{:016x}", hash_xxh3_hash64(url.as_bytes()));
if size_adjust {
    name.push_str("-s")
}
if preload {
    name.push_str(".p")
}

Line 2 starts the name with a 16-character hash of the file's Google address (xxh3, a fast hash). Lines 3 to 5 add -s when the fallback font is size-adjusted; lines 6 to 8 add .p when the file is to be preloaded. When Turbopack writes it to .next/static/media, it adds 13 characters of a hash of the file's content before the extension (chunking_context.rs, lines 794 to 826). Lora's build wrote 7 files; only c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2 has .p. The marks are read back when the build writes the font manifest:

Reading the names back: next-api/src/font.rs, lines 68 to 75Rust
let has_fonts = !font_paths.is_empty();
let using_size_adjust = font_paths.iter().any(|path| path.contains("-s"));

let font_paths = font_paths
    .into_iter()
    .filter(|path| path.contains(".p."))
    .map(RcStr::from)
    .collect::<Vec<_>>();

Line 2: any file with -s means the app uses a size-adjusted fallback. Lines 4 to 8 keep only the files with .p.. The build wrote:

Recorded from the build: .next/server/next-font-manifest.jsonJSON
{
  "app": {
    "[project]/app/_not-found/page": [
      "static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2"
    ],
    "[project]/app/page": [
      "static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2"
    ]
  },
  "appUsingSizeAdjust": true,
  "pages": {},
  "pagesUsingSizeAdjust": false
}

For each page that uses the font, the one file to preload. When the server renders a page, it looks the page up here (get-preloadable-fonts.tsx) and asks React to preload the file (get-layer-assets.tsx), which React sends as a header. The recorded home page has it: link: </_next/static/media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2". appUsingSizeAdjust: true adds <meta name="next-size-adjust"> to the page's head too.

Left: during next build, Turbopack asks fonts.googleapis.com for Lora's CSS, finds one font file per subset, and downloads each. Right: each file is renamed with a hash of its address, -s when the fallback is size-adjusted, and .p when it's preloaded, then written to .next/static/media. The font manifest lists only the .p files; the server sends them as a preload Link header.next buildGET …/css2?family=Lora/* latin */ src: url(…)download 7 font filesstatic/mediac3d9…-s.p.….woff202ee…-s.….woff2…5 more…-s: size-adjusted fallback.p: preload this onenext-font-manifestlists only the .p filesLink: rel=preloadon each page that uses it
The build asks Google once, as Chrome 104 would, downloads every file the CSS names, and marks each file's name: -s for a size-adjusted fallback, .p for a file to preload. The manifest finds the files to preload by that mark.

With next build --webpack, the same work is done in JavaScript, by the loader in packages/font/src/google/loader.ts, with the same marks in the names. Both only run while building: a visitor's browser never asks Google.

Computing size-adjust fallbacks

Until the font file arrives, the browser draws the text with the fallback font. Optimizing fonts recorded the rule next/font writes for Lora: Times New Roman, at size-adjust: 115.2%. The numbers come from a file Next.js ships, next/dist/server/capsize-font-metrics.json: the measurements of Google's fonts and common system fonts, copied from the @capsizecss/metrics package when Next.js itself is built (taskfile.js, lines 189 to 199). Each entry has the font's category, its ascent and descent (how far letters reach above and below the line), lineGap, unitsPerEm (the grid the font is drawn on) and xWidthAvg, the average width of a letter in ordinary text. Lora's: 1006, -274, 0, 1000 and 468. Times New Roman's xWidthAvg is 832, on a grid of 2048.

Working out the fallback: shortened from google/font_fallback.rs (lines 149 to 176)Rust
let fallback = if metrics.category == "serif" {
    &DEFAULT_SERIF_FONT
} else {
    &DEFAULT_SANS_SERIF_FONT
};

let metrics = if adjust {
    // …
    let main_font_avg_width = metrics.x_width_avg as f64 / metrics.units_per_em as f64;
    let fallback_metrics = font_metrics_map.0.get(&fallback.capsize_key).unwrap();
    let fallback_font_avg_width =
        fallback_metrics.x_width_avg as f64 / fallback.units_per_em as f64;
    let size_adjust = main_font_avg_width / fallback_font_avg_width;

    let ascent = metrics.ascent as f64 / (metrics.units_per_em as f64 * size_adjust);
    let descent = metrics.descent as f64 / (metrics.units_per_em as f64 * size_adjust);
    let line_gap = metrics.line_gap as f64 / (metrics.units_per_em as f64 * size_adjust);

    Some(FontAdjustment {
        ascent,
        descent,
        line_gap,
        size_adjust,
    })
} else {
    None
};
  1. Lines 1 to 5: a serif font gets Times New Roman; every other category, even handwriting, gets Arial.
  2. Lines 9 to 13: size_adjust is the font's average letter width divided by the fallback's, each measured as a share of its own grid. For Lora: 468 / 1000 = 0.468, and 832 / 2048 = 0.406, so 1.152.
  3. Lines 15 to 17: the room above and below the letters, and between lines, is Lora's, divided by the same number, because the fallback is drawn that much bigger: 1006 / (1000 × 1.152) = 0.8733.
Left card: Lora, average letter 468 of 1000 units, 0.468. Right card: Times New Roman, 832 of 2048 units, 0.406. An arrow from both to a big card: size-adjust 115.2%, because 0.468 divided by 0.406 is 1.152. Below: ascent-override is Lora's ascent, 1006 of 1000, divided by 1.152: 87.33%.Lora: 468 / 10000.468 per unitTimes: 832 / 20480.406 per unitsize-adjust 115.2%0.468 ÷ 0.406 = 1.152ascent: 1006 ÷ (1000 × 1.152) = 87.33%
Lora's average letter is wider than Times New Roman's, so the fallback is drawn 1.152 times bigger. The line's room above and below the letters is then Lora's, divided by the same number.
Writing the fallback rule: shortened from next_font/stylesheet.rs (lines 17 to 97)Rust
let override_properties = match &fallback.adjustment {
    None => "".to_owned(),
    Some(adjustment) => formatdoc!(
        r#"
        ascent-override: {}%;
        descent-override: {}%;
        line-gap-override: {}%;
        size-adjust: {}%;
    "#,
        format_fixed_percentage(adjustment.ascent),
        format_fixed_percentage(adjustment.descent.abs()),
        format_fixed_percentage(adjustment.line_gap),
        format_fixed_percentage(adjustment.size_adjust)
    ),
};

res.push_str(&formatdoc!(
    r#"
    @font-face {{
        font-family: '{}';
        src: local("{}");
        {}
    }}
"#,
    fallback.scoped_font_family,
    fallback.local_font_family,
    override_properties
));
// …
fn format_fixed_percentage(value: f64) -> String {
    format!("{:.2}", value * 100.0)
}

Lines 1 to 15 write the four numbers as percentages with two decimals (lines 30 to 32), and lines 17 to 28 the @font-face rule: a font named Lora Fallback whose file is local("Times New Roman"), the one already on the visitor's computer. The class then lists Lora first and Lora Fallback second. This is what the build wrote, laid out to read; the minifier shortened 115.20% to 115.2%:

Recorded from the build: Lora's preloaded file, its fallback, and the class (CSS chunk, laid out)CSS
@font-face {
  font-family:Lora;
  font-style:normal;
  font-weight:400 700;
  font-display:swap;
  src:url(../media/c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2)format("woff2");
  unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
}
@font-face {
  font-family:Lora Fallback;
  src:local(Times New Roman);
  ascent-override:87.33%;
  descent-override:23.78%;
  line-gap-override:0.0%;
  size-adjust:115.2%
}
.KWfbtq_className {
  font-family:Lora,Lora Fallback;
  font-style:normal
}

Pick other fonts. The page works the numbers out with the same formula, from their entries in the same file:

Playground: work out a fallback font
The rule the formula gives for LoraCSS
@font-face {
  font-family: 'Lora Fallback';
  src: local("Times New Roman");
  ascent-override: 87.33%;
  descent-override: 23.78%;
  line-gap-override: 0.00%;
  size-adjust: 115.20%;
}
Recorded from the build: the rule next build wrote for Lora, laid outCSS
@font-face {
  font-family:Lora Fallback;
  src:local(Times New Roman);
  ascent-override:87.33%;
  descent-override:23.78%;
  line-gap-override:0.0%;
  size-adjust:115.2%
}
Try it yourself
Lora is serif, so its fallback is Times New Roman, drawn at 115.20%

Lora's average letter is 468 of 1000 units wide; Times New Roman's is 832 of 2048. size-adjust is the first divided by the second. A build of this font was recorded: its rule is below the computed one, with the same numbers.

The numbers are each font's entry in capsize-font-metrics.json, the file Next.js 16.4.3 ships and reads during the build. The formula is lookup_fallback's, written out for this page. Reset goes back to Lora.

Two options change this, and the source shows how each one reaches this code:

  • fallback: ["Georgia", "serif"] makes get_font_fallback skip the metrics entirely and use your list (google/font_fallback.rs, lines 67 and 68), which is why Optimizing fonts found no Lora Fallback rule with it.
  • adjustFontFallback: false should leave the fallback without the four numbers. In the recorded Turbopack build it changed nothing: Lora, adjustFontFallback: false in the font playground has the same size-adjust rule and the same -s names. The struct the options are read into explains it:
The options Turbopack reads: google/request.rs, lines 7 to 25Rust
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct NextFontRequest {
    pub import: RcStr,
    pub arguments: Vec<NextFontRequestArguments>,
}

#[derive(Clone, Debug, Default, Deserialize)]
pub(super) struct NextFontRequestArguments {
    pub weight: Option<OneOrManyStrings>,
    pub subsets: Option<Vec<RcStr>>,
    pub style: Option<OneOrManyStrings>,
    pub display: Option<RcStr>,
    pub preload: Option<bool>,
    pub axes: Option<Vec<RcStr>>,
    pub fallback: Option<Vec<RcStr>>,
    pub adjust_font_fallback: Option<bool>,
    pub variable: Option<RcStr>,
}

Line 2 tells the reader of the outer object that its JSON names are written camelCase. The struct for your options, from line 8, has no such line, so its fields are matched by their own names: adjust_font_fallback on line 17 waits for a key spelled with underscores, and the adjustFontFallback you write never fills it. One-word options like subsets and fallback match either way. An empty field becomes true (options.rs, line 196). Next.js's issue tracker has an open report of the same behavior (issue 74134). The webpack loader reads the option correctly. Treat this as a fact about 16.4.3 with Turbopack, not as how the option is meant to work.

A font of your own, from next/font/local, has no entry in the metrics file. Turbopack measures the file itself instead, with the allsorts font library: the average width of a sample of letters and spaces, weighted like English text (line 31), and the ascent and descent the file states (local/font_fallback.rs, lines 92 to 166).

Reading your own build's images and fonts

In a Next.js 16.4.3 project, you can check all of this on your own server. Read it to learn and to debug, never from your app's code:

  • Open a photo's /_next/image address in a tab and read the headers: X-Nextjs-Cache says whether it was made now, and a 400's body says which check refused it.
  • After next start has served some photos, list .next/cache/images: one folder per key, one file per folder. Deleting the folder empties the cache; the next request for each key is a MISS.
  • After next build, list .next/static/media for your fonts, and read .next/server/next-font-manifest.json to see which files each page preloads.
  • To check a fallback, search the built CSS in .next/static/chunks for Fallback, and compare its size-adjust with the playground's.
  • A build machine that can't reach Google fails at next/font. The error itself names the ways out: next/font/local with the files in your project, or HTTP_PROXY/HTTPS_PROXY pointing at a proxy that can.

Sort these: when does each one happen?

When does it happen?
  • Lora's woff2 files are downloaded from Google
  • size-adjust: 115.2% is worked out
  • .p is added to the latin file's name
  • w=500 is refused with 400
  • The cache key is hashed
  • The first bytes show oolong.jpg is a PNG
  • sharp resizes the photo to 640 pixels
  • A file named 14400.<expireAt>.…webp is written
Try it yourself
0 of 8 correct

Sort each step: while next build runs, on every /_next/image request, or only when the cache needs a new entry.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The optimizer resizes a photo on every request." No. Only when the cache needs a new entry: on a miss, and on a stale entry's refresh if the old copy can't be reused. The recorded again request was a HIT, sent from .next/cache/images.
  • "The optimizer trusts a file's name." No. It reads the first bytes: the PNG named oolong.jpg came back as image/png.
  • "The cache key changes when the photo changes." No. It's the address, width, quality and format. The original's ETag is kept in the file's name, and an entry is only made again once it's stale.
  • "A big w makes a big image." No. sharp never enlarges: w=3840 of a 1,600-pixel photo is 1,600 pixels wide.
  • "next/font fetches the font from Google when the page loads." No. The build fetches it; without Google the build stops, and the recorded page preloads a file from its own site.
  • "size-adjust is measured in the browser." No. It's computed during the build from the metrics file Next.js ships, and written into the CSS.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upPredict a refusal

With the default config, what is the body of the answer to /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=90?

Starter codeTypeScript
const sizes = [...(deviceSizes || []), ...(imageSizes || [])]

if (isDev) {
  sizes.push(BLUR_IMG_SIZE)
}

const isValidSize =
  sizes.includes(width) || (isDev && width <= BLUR_IMG_SIZE)

if (!isValidSize) {
  return {
    errorMessage: `"w" parameter (width) of ${width} is not allowed`,
  }
}

const quality = parseInt(q, 10)

if (isNaN(quality) || quality < 1 || quality > 100) {
  return {
    errorMessage:
      '"q" parameter (quality) must be an integer between 1 and 100',
  }
}

if (
  qualities &&
  !qualities.includes(quality) &&
  !(isDev && quality === BLUR_QUALITY)
) {
  return {
    errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
  }
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind it in the source: the preload mark

    Which mark in a font file's name makes Next.js preload it?

    Starter codeRust
    let has_fonts = !font_paths.is_empty();
    let using_size_adjust = font_paths.iter().any(|path| path.contains("-s"));
    
    let font_paths = font_paths
        .into_iter()
        .filter(|path| path.contains(".p."))
        .map(RcStr::from)
        .collect::<Vec<_>>();

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeSame photo, two Accept headers

      Both requests are misses. How many folders do they add to .next/cache/images?

      Starter codeText
      GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
      Accept: image/avif,image/webp,…
      
      GET /_next/image?url=%2Fteas%2Fgreen-tea.jpg&w=640&q=75
      (no Accept header)

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the type of a disguised photo

        public/teas/oolong.jpg is really a PNG. Asked for with no Accept header, what Content-Type comes back?

        Starter codeTypeScript
        const maxAge = Math.max(
          nextConfig.images.minimumCacheTTL,
          getMaxAge(imageUpstream.cacheControl)
        )
        const upstreamType = await detectContentType(upstreamBuffer)
        
        if (
          !upstreamType ||
          !upstreamType.startsWith('image/') ||
          upstreamType.includes(',')
        ) {
          opts.logger?.error(
            "The requested resource isn't a valid image for",
            href,
            'received',
            upstreamType
          )
          throw new ImageError(400, "The requested resource isn't a valid image.")
        }
        if (
          upstreamType.startsWith('image/svg') &&
          !nextConfig.images.dangerouslyAllowSVG
        ) {
          opts.logger?.error(
            `The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
          )
          throw new ImageError(
            400,
            '"url" parameter is valid but image type is not allowed'
          )
        }
        // …
        if (BYPASS_TYPES.includes(upstreamType)) {
          return {
            buffer: upstreamBuffer,
            contentType: upstreamType,
            maxAge,
            etag: upstreamEtag,
            upstreamEtag,
          }
        }
        
        let contentType: string
        
        if (mimeType) {
          contentType = mimeType
        } else if (upstreamType === WEBP || upstreamType === AVIF) {
          // Downlevel WebP and AVIF when the client does not advertise support.
          contentType = JPEG
        } else {
          contentType = upstreamType
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind it in the source: the option that gets lost

          Which field of these structs never receives adjustFontFallback: false?

          Starter codeRust
          #[derive(Debug, Deserialize)]
          #[serde(rename_all = "camelCase")]
          pub(super) struct NextFontRequest {
              pub import: RcStr,
              pub arguments: Vec<NextFontRequestArguments>,
          }
          
          #[derive(Clone, Debug, Default, Deserialize)]
          pub(super) struct NextFontRequestArguments {
              pub weight: Option<OneOrManyStrings>,
              pub subsets: Option<Vec<RcStr>>,
              pub style: Option<OneOrManyStrings>,
              pub display: Option<RcStr>,
              pub preload: Option<bool>,
              pub axes: Option<Vec<RcStr>>,
              pub fallback: Option<Vec<RcStr>>,
              pub adjust_font_fallback: Option<bool>,
              pub variable: Option<RcStr>,
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCompute Inter's size-adjust

            What size-adjust does next/font write for Inter's fallback?

            Starter codeText
            Inter:  category sans-serif, unitsPerEm 2048, xWidthAvg 978
            Arial:  unitsPerEm 2048, xWidthAvg 913
            Times New Roman: unitsPerEm 2048, xWidthAvg 832

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask when it happens: during the build, on every request, or only when the cache needs a new entry.

              The images and fonts under the hood quiz · 7 questionsScore: first tries count
              1. Question 1 of 7A request asks /_next/image for w=500. Where does the 400 come from?

                Choose an answer to see the explanation.

              2. Question 2 of 7Which of these is not part of the image cache key?

                Choose an answer to see the explanation.

              3. Question 3 of 7/logo.svg passes the url, w and q checks. Why does it still get 400 with the default config?

                Choose an answer to see the explanation.

              4. Question 4 of 7When does Next.js download Lora's font files from Google?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does .p in c3d9c74ef0d9c07b-s.p.23aeddxv5enbo.woff2 tell Next.js?

                Choose an answer to see the explanation.

              6. Question 6 of 7Where does size-adjust: 115.2% for Lora come from?

                Choose an answer to see the explanation.

              7. Question 7 of 7The recording asked for the green tea photo at w=3840. The original is 1,600 pixels wide. What came back?

                Choose an answer to see the explanation.

              Key takeaways

              • validateParams checks url, w and q on every request, against the patterns, deviceSizes, imageSizes and qualities, and a refusal is 400 with its message.
              • A good request's key is the SHA-256 of the cache version, address, width, quality and format; the entry is a folder named after it in .next/cache/images.
              • Only a new entry, for a miss or a stale entry's refresh, fetches the original (through the server itself, or over the network after a private-address check) and reads its type from its bytes; sharp, which never enlarges, runs unless an unchanged original lets a refresh reuse the old copy.
              • The file's name holds maxAge, expireAt, the ETag, the original's ETag and the extension; a stale entry is sent once more while a new one is made.
              • next/font/google fetches Google's CSS and files during next build, as Chrome 104, and marks file names with -s and .p; the font manifest preloads the .p files.
              • size-adjust is the font's average letter width over the fallback's, from the metrics Next.js ships; in 16.4.3 with Turbopack, adjustFontFallback: false doesn't reach that code.

              Remember the one-liner.
              Images are checked, keyed and resized when a request misses the disk cache; fonts are downloaded, marked and measured while the app builds.

              Coming next: Adapters and deployment outputs. It reads required-server-files.json, what an adapter receives, and how deployment ids keep old pages and new servers apart.

              CompleteFrontend Clear concepts. Working examples.