knext

Image optimization

next/image optimization via sharp, with optimized variants persisted to the object store so they survive scale-to-zero.

knext serves next/image runtime optimization (the /_next/image endpoint: resize, quality, and format negotiation to AVIF/WebP) using sharp. Optimization is configured in next.config.ts:

next.config.ts
images: {
  formats: ['image/avif', 'image/webp'],
  remotePatterns: [],
},
  • formats negotiates modern formats per the request's Accept header.
  • remotePatterns is an explicit allowlist. Empty (the default) means local images only — there is no open optimizer, so the endpoint cannot be turned into an SSRF proxy. Add trusted hosts as needed.

Variants survive scale-to-zero

Optimizing an image is CPU-expensive, and under Knative scale-to-zero every cold pod starts with an empty local cache. Without intervention, each freshly-woken pod would re-optimize images another pod already produced — burning the cold-start CPU budget on redundant work.

knext keeps optimized variants outside the pod, so a variant computed once is reused by every later pod. This is the same "survives the cold start" property as the bytecode cache: the work outlives the pod that did it.

Stored through the cache handler (default)

When your app uses the knext cache handler (every knext create app does), the knext adapter turns on Next's images.customCacheHandler option at build time. Next then stores each optimized variant through the cache handler instead of writing it to local disk:

  • On the Bun runtime with Redis configured, variants live in Redis next to your ISR entries, so every pod shares them and they survive scale-to-zero.
  • On the Node runtime, the standalone image cannot reach Redis yet, so the cache handler falls back to the per-pod memory cache below. Variants are re-optimized after each scale-to-zero.
  • Without Redis, variants are kept in each pod's memory, capped at 32 MiB by default. Least recently used variants are dropped first. Set KNEXT_IMAGE_CACHE_MEMORY_BYTES to change the cap.

Because nothing is written to disk, the app needs no writable volume for images, which keeps a scratch volume off every cold start (see Read-only root filesystem).

To keep Next's on-disk image cache instead, build with KNEXT_IMAGE_CACHE_HANDLER=0 and set spec.security.writableCache: true on your NextApp. The object-store sync below then applies.

Object-store sync (disk cache)

When images are cached on disk (the opt-out above, a custom cacheHandler, or a Next.js version without images.customCacheHandler), knext syncs that directory to the object store (GCS / S3 / MinIO / Azure). Next.js writes optimized variants to a pod-local directory (.next/cache/images/<cacheKey>/...), where cacheKey is a content hash of (src, width, quality, mimeType), and knext syncs the directory Next already writes rather than intercepting it:

PhaseBehavior
Restore (startup)Downloads every persisted variant from the store into the local cache dir, skipping files that already exist locally — warming the cache before the server starts.
Watch + push (runtime)Watches the cache dir; when Next writes a new variant, the (debounced) variant directory is uploaded to the store under <prefix>/<cacheKey>/.

The sync is guarded by STORAGE_BUCKET: when it is unset, the sync is a no-op and Next falls back to pod-local caching. It also stands down when images are stored through the cache handler, or when the cache directory is not writable. Both restore and push are best-effort — a store outage degrades to local-only optimization rather than crashing the pod.

Shipping sharp with your image

This section is about the vinext build. On the default standalone build, sharp ships in the standalone output's node_modules that Next traces into the image, and you do nothing extra.

On the vinext build, your app compiles to a single executable, and sharp is not entirely JavaScript — the part that actually resizes pixels is a native library. A compiled executable can only load code that was built into it, so that native library has to travel as a real file next to the binary, in a native/ directory.

knext build creates that directory for you, and the generated Dockerfile copies it in. You only need to know this exists if you write your own Dockerfile or copy the binary somewhere by hand.

The native library is platform-specific, and the platform that matters is the container image's (Linux/musl), not your laptop's. knext build stages the image's addon set regardless of where you build: when your local install already has it (a Linux host) it is copied, and when it does not (a macOS host holds only macOS addons) the build downloads the version your app's installed sharp resolves to, as pinned in your lockfile (bun.lock or npm's package-lock.json — either works), and verifies the download against the lockfile's integrity hash before staging it. If neither your install nor your lockfile has the image's addon, the build fails and names the missing package — it never ships an image that cannot load sharp.

At boot, the binary picks the addon matching the platform it is actually running on — never just the first directory it finds — and if nothing staged matches, it fails with an error naming the platforms involved instead of crashing on an unreadable binary. If you need to override the selection (say, a hand-built image with a custom layout), set KNEXT_SHARP_ADDON to the absolute path of the .node file inside its @img tree.

Upgrading: if you built an app image on macOS before this behaviour existed, the old addon selection is baked into that binary — rebuild and redeploy with the current knext build to get working image optimization; no configuration change is needed.

The vinext build with runtime: 'node' (uncompiled vinext on Node, not the compiled executable) gets the same treatment: knext build restages sharp's package and its platform addon into the image's build output on every build, so the same platform mismatch a macOS build host would otherwise produce is fixed automatically. An app scaffolded before this behaviour existed has an older knext-node-entry.mjs; replace it with the one from a freshly created app to pick it up.

If your app imports sharp and its addon cannot load, the pod does not start — the generated server entry imports sharp at boot, so a missing or wrong-platform addon is a startup failure with an error naming the cause, not a silent degradation. Only an app that never pulls sharp into its bundle (no next/image) runs with an empty native/; for such an app /_next/image answers 200 and returns the original image, unresized.

The quickest check against a running container:

curl -sI -H 'Accept: image/avif,image/webp' \
  'http://localhost:3000/_next/image?url=%2Fyour-image.png&w=256&q=75' | grep -i content-type

image/avif or image/webp means optimization is working. Getting back image/png — the format you asked it to convert from — means sharp could not load, and the endpoint is passing the original through.

Verified, not asserted. Every build runs a check that starts the real production container, requests an optimized image, and fails the build unless it comes back as a modern format and smaller than the source. The check also tests itself against a deliberately broken optimizer first, so it cannot pass by accident.

A project-wide custom loader works on the vinext build target too. Set images: { loader: 'custom', loaderFile: './image-loader.js' } in next.config.ts (the file's default export is the loader) and every next/image uses it, with a per-width srcSet, instead of the built-in /_next/image endpoint. A missing file, or a loader other than default/custom next to loaderFile, fails the build, as in Next.js. Your loader can still return /_next/image URLs for some images (/_next/image/?url=${src}&w=${width}&q=${quality}) to keep them on the built-in optimizer; those are served in both the App Router and the Pages Router.

  • Scale to zero — why a cold pod must not re-do work, and the autoscaling model.
  • Bytecode cache — the sibling "survives the cold start" feature for V8 bytecode.
  • Multi-cloud — the object store (GCS / S3 / MinIO / Azure) that holds the persisted variants.

On this page