DEV Community

casanovalabs
casanovalabs

Posted on • Originally published at casanovalabs.com

Serving AI-Generated Images at Scale on Next.js: Formats, Cache and the App Router Traps

next/image is built for a mostly-static image set: a handful of hero shots, a product catalogue, a blog's cover images. Point it at a pipeline that generates a fresh image per user action — an edited listing photo, a new render — and a few defaults that are invisible in the common case start to matter.

We build CasaNova Labs, an AI studio for real estate photo and video editing, and every output the product generates gets served back through exactly this kind of pipeline. Here is what Next.js's own reference docs say about the parts that actually bite at that volume.

Format negotiation runs off the Accept header, not a version check

The formats config controls which encodings the optimizer is allowed to produce. The default is ['image/webp']; Next.js reads the request's Accept header to pick the best match, and if more than one configured format matches, "the array order matters" — the first hit in the array wins.

module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
  },
}
Enter fullscreen mode Exit fullscreen mode

The docs are specific about the cost of adding AVIF: it "generally takes 50% longer to encode but it compresses 20% smaller compared to WebP," and when multiple formats are configured, "Next.js will cache each format separately" — meaning every source image now produces multiple cached variants, not one. Worth deciding on purpose, not by copying a starter config.

The cache TTL is a floor, not a fact about the file

minimumCacheTTL sets the minimum seconds an optimized image is cached for; the default is 14400 (4 hours). The actual expiration is "defined by either the minimumCacheTTL or the upstream image Cache-Control header, whichever is larger" — so a slow-changing config value can still be overridden upward by whatever header the origin image sent.

The sharper edge is what happens when a source image changes: "there is no mechanism to invalidate the cache at this time." The documented fix is to change the src prop (a new URL is a new cache entry) or delete the cached file under <distDir>/cache/images. For anything that regenerates the same nominal image — a re-edited photo served from the same path — that's the detail that decides whether users see the old version for up to minimumCacheTTL seconds.

Static imports skip the whole caching question

For images that ship with the build rather than being generated at request time, the docs point at a different mechanism entirely: a Static Image Import. Importing a file directly, import hero from './hero.jpg', makes Next.js "automatically hash the file contents and cache the image forever with a Cache-Control header of immutable" — no TTL to tune, no invalidation problem, because a content hash in the URL means a changed file is a new URL. That only applies to build-time assets; anything generated after a request comes in still goes through the remotePatterns and minimumCacheTTL path above. Worth knowing which of the two a given image actually is before reaching for cache config.

There's a related edge worth checking before wiring up authenticated image sources: "the Image Optimization API using the default loader will not forward headers when fetching the src image." A signed or authenticated URL for a freshly generated image can fail silently through the optimizer for exactly this reason — the documented way around it is the unoptimized prop, which serves the source as-is instead of routing it through /_next/image.

Every external source has to be allow-listed

If the images being served don't live under /public, remotePatterns is mandatory — Next.js will 400 anything that doesn't match:

module.exports = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'assets.example.com', pathname: '/renders/**' },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

domains, the older config, is deprecated since Next.js 14 specifically because it "does not support wildcard pattern matching and it cannot restrict protocol, port, or pathname" — for a storage bucket or CDN host serving generated output, that's a meaningfully wider allow surface than remotePatterns needs to grant.

sizes decides how much of the srcset gets built

Without a sizes prop, "the browser assumes the image will be as wide as the viewport (100vw)," which the docs call out directly as a cause of unnecessarily large downloads. sizes also changes what Next.js generates on the backend: without it, a limited srcset (roughly 1x/2x); with it, a full breakpoint-based srcset. For a grid of AI-generated thumbnails at a fixed card width, an unset sizes prop is a real bandwidth cost, not a cosmetic detail.

The quality allowlist is enforced server-side

As of Next.js 16, qualities is a required config — an explicit allowlist of quality values the optimizer will produce, defaulting to [75]. A quality prop that doesn't match an allowed value snaps to the closest one; hitting the optimization endpoint directly with a disallowed value returns 400. It exists, per the docs, because "unrestricted access could allow malicious actors to optimize more qualities than intended" — worth setting deliberately once output is coming from more than one static list of assets.

None of these are bugs in Next.js — they're defaults tuned for a mostly-static image set, documented plainly, and worth revisiting the moment a pipeline starts generating images instead of just storing them. That's exactly the case CasaNova Labs builds for on every listing photo it renders back to a browser.

Top comments (0)