ditherkit
@ditherkit/next

StaticImageData

How imported images get deterministic, cacheable URLs from their webpack hash.

<DitheredImageSSR src={importedImage} /> is the most ergonomic way to use @ditherkit/next. You import an image file the normal Next.js way, pass it as src, and everything else — cache keys, route URLs, invalidation — happens automatically. This page explains how.

The normal import pattern

Next.js (via webpack or Turbopack) understands image imports:

import portrait from '@/assets/portrait.jpg'

// `portrait` is a StaticImageData object:
// {
//   src: '/_next/static/media/portrait.a1b2c3d4.jpg',
//   width: 1200,
//   height: 1800,
//   blurDataURL: 'data:image/...',
// }

The bundler copies the file to .next/static/media/ with a content hash in the filename (portrait.a1b2c3d4.jpg). That hash changes whenever the source file changes, and that's the hook we use for cache invalidation.

How the cache key is generated

When you render:

<DitheredImageSSR
  src={portrait}
  alt="..."
  algorithm="Floyd-Steinberg"
/>

The component runs through these steps:

  1. Take the webpack src path from portrait.src. For /_next/static/media/portrait.a1b2c3d4.jpg that's the entire string. The webpack content hash is embedded in the filename, so the path itself changes whenever the source changes.
  2. Serialise the dither parameters into a URL-safe string: fs_th128_pl000000ffffff_brp0_ctp0_w800_h600_px1_gsa. Each underscore-separated chunk encodes one parameter (algorithm slug, threshold, palette hex, brightness, contrast, width, height, pixelSize, grayscale).
  3. Combine src and params: src + '|' + paramString.
  4. MD5 the combined string and take the first 12 hex chars. That's the cacheKey.
  5. Build the final URL, rooted at your routeBase:
    {routeBase}/{cacheKey}/{paramString}?src={encodedSrc}
    With the recommended routeBase: '/api/dithered' that becomes something like:
    /api/dithered/a1b2c3d4e5f6/fs_th128_pl000000ffffff_brp0_ctp0_w800_h600_px1_gsa?src=%2F_next%2Fstatic%2Fmedia%2Fportrait.a1b2c3d4.jpg

The URL is deterministic — the same source file with the same dither parameters always produces the same URL. That's what makes ISR caching work: once a URL is cached, every subsequent request with the same parameters hits the cache instead of re-running Sharp.

The route handler also recomputes the cacheKey at request time and rejects mismatches with a 400. This binds the path-segment cacheKey to the ?src= query so an attacker can't mint distinct cache entries by varying the path independently of the query.

Invalidation happens automatically

The webpack hash changes whenever the source file's contents change, which changes the src string, which changes the cacheKey:

  • You edit portrait.jpg → webpack generates a new filename hash → the new portrait.src produces a new cacheKey → a new URL → a fresh cache entry.
  • The old cache entry still exists at the old URL but is never referenced again. It ages out of the CDN naturally per your cache TTL.
  • You never manually invalidate anything. Just redeploy with the updated source file.

Dither parameters are part of the URL too, so changing the algorithm or palette also produces a fresh cache key without touching the source file.

Reading from disk at request time

When the unified route handler at ${routeBase}/[cacheKey]/[...params] receives a request, it:

  1. Extracts the src query parameter — the webpack path /_next/static/media/portrait.a1b2c3d4.jpg.
  2. Recomputes the cacheKey from src + '|' + paramString and rejects with 400 if it doesn't match the path segment.
  3. Classifies the source as same-origin local (because it starts with /).
  4. Reads the file from disk at the corresponding path in .next/static/media/.
  5. Falls back to an HTTP fetch if the file isn't on disk. This happens in Turbopack dev mode where static assets are served dynamically.
  6. Runs Sharp + @ditherkit/core on the bytes.
  7. Returns WebP with permanent cache headers in production.

The on-disk read is fast — just a fs.readFileSync. The HTTP fallback is slower but only matters in development.

Vercel serverless

On Vercel, _next/static/ files are deployed exclusively as CDN assets — they are not bundled into the serverless function container. The on-disk readFileSync always fails, and the route handler falls back to fetching the image over HTTP from the CDN. This HTTP fallback is the primary read path on Vercel, not a dev-mode workaround.

This is automatic and requires no extra configuration beyond calling withDitherkit() in your next.config.

Preview deployments with deployment protection

On preview deployments with deployment protection enabled, the internal HTTP fetch returns 401 because the CDN requires authentication. To fix this:

  1. Go to your Vercel project → SettingsDeployment Protection
  2. Enable Protection Bypass for Automation
  3. Redeploy

This makes VERCEL_AUTOMATION_BYPASS_SECRET available as a system environment variable. The route handler automatically sends it as an x-vercel-protection-bypass header on the internal fetch, bypassing protection for that request only. Production deployments are unaffected.

Imports from any folder structure

The source file can live anywhere. The import path is what matters for the developer, but the bundler output path is what goes into the URL. Webpack/Turbopack copies every imported image into the same flat .next/static/media/ directory regardless of where it came from, so:

import hero from '@/assets/marketing/hero.jpg'
import avatar from '@/components/profile/avatar.png'
import screenshot from '../../fixtures/test-image.jpg'

All three end up in .next/static/media/, all three get the same treatment, all three work the same way with <DitheredImageSSR />.

No configuration needed, no "registry" to maintain.

Why not just use the src URL directly?

The route handler does take the webpack src path as a query parameter — it's how the handler knows which file to read. But the cache key in the URL path is deliberately separate because:

  • Cache keys are short (12 hex chars) whereas webpack src paths are long (/_next/static/media/portrait.a1b2c3d4.jpg).
  • The cache key sits in the URL path (which CDNs key their cache on), not the query string (which some CDNs ignore).
  • Encoding the src path in the path would produce ugly URLs full of %2F escape sequences.

So the cache key is a content-addressed handle that's cheap to match against a CDN cache, and the full src path rides along in the query string for the server to actually find the file on disk. The server-side cacheKey-vs-src binding check ensures the two always agree.

On this page