ditherkit
@ditherkit/next

@ditherkit/next

Server-rendered, ISR-cached dithering for Next.js apps. App Router and Pages Router.

@ditherkit/next is the production-ready face of the toolkit. It processes images on the server with Sharp, caches the results with Next.js's ISR, and ships them through next/image — so you get dithered images with the same performance characteristics as any other asset on your site: cache-friendly, CDN-distributable, SEO-indexable, Cache-Control: immutable in production.

If you need interactive dithering — users dragging sliders to preview effects — reach for @ditherkit/react instead.

@ditherkit/next does not re-export @ditherkit/react. If you want both server-rendered output and interactive client-side dithering in the same Next.js app, install both packages. They're designed to coexist.

App Router and Pages Router

The runtime entry @ditherkit/next is identical for both routers — the component, URL serialisation, Sharp pipeline, and cache key derivation are shared. Only the route handler subpath differs:

  • @ditherkit/next/route/app — App Router. Pre-built GET handler using NextRequest / NextResponse. Re-export with export { GET } from '@ditherkit/next/route/app'.
  • @ditherkit/next/route/pages — Pages Router. Pre-built default handler using NextApiRequest / NextApiResponse. Re-export with export { default } from '@ditherkit/next/route/pages'.

Pick one. Don't mix them in the same app.

Pages in this section

  • <DitheredImageSSR /> — the component for single dithered images. Wraps next/image with ISR caching. Props, behaviour, the dimension contract.
  • <DitheredPicture /> — zero-JS responsive dithered images. Renders <picture> with multiple <source media> variants at different resolutions. Includes computeVariants helper, art direction, crop/cover modes.
  • Route setup — the singleton pattern, both router variants, and the legacy createDitherkit factory for multi-instance use cases.
  • StaticImageData — how imported images become deterministic, cacheable URLs via webpack hash extraction.
  • External URLs — the other route, for src strings like "https://picsum.photos/...".
  • ISR caching — how the cache is keyed, dev vs prod behaviour, WebP encoding, invalidation.

One-minute overview

One withDitherkit call in next.config, the component imported directly from @ditherkit/next, and the route handler is a one-line re-export. No lib/ files, no factory call.

App Router

// next.config.ts — the ONE place ditherkit is configured
import type { NextConfig } from 'next'
import { withDitherkit } from '@ditherkit/next/config'

const config: NextConfig = {
  // your existing config
}

export default withDitherkit(config, {
  routeBase: '/api/dithered',
  // Default is deny-all. Opt in to allow external URLs:
  // externalImages: { inheritRemotePatterns: true },
})
// app/page.tsx
import { DitheredImageSSR } from '@ditherkit/next'
import portrait from '@/assets/portrait.jpg'

export default function Page() {
  return (
    <DitheredImageSSR
      src={portrait}
      alt="A portrait, dithered on the server"
      algorithm="Atkinson"
      priority
    />
  )
}
// app/api/dithered/[cacheKey]/[...params]/route.ts
export { GET } from '@ditherkit/next/route/app'

Pages Router

Same setup. Only the route file changes:

// pages/api/dithered/[cacheKey]/[...params].ts
export { default } from '@ditherkit/next/route/pages'

The <DitheredImageSSR /> import and the withDitherkit call in next.config are byte-identical between routers — the singleton component reads its routeBase from the env bridge regardless of which router is rendering it.

How it works without a lib file

withDitherkit writes the resolved routeBase, externalImages allowlist, and DOS limits into nextConfig.env. Webpack's DefinePlugin inlines those values at build time, and both the component (@ditherkit/next) and the route handler subpaths (@ditherkit/next/route/{app,pages}) read them via direct process.env.NAME access. Because the access is inlined, there's no runtime cost and no shared JavaScript module is needed to thread configuration between the component and the route handler.

For a deeper explanation — including the createDitherkit factory escape hatch for multi-instance use cases — see Route setup.

How it fits together

<DitheredImageSSR src={staticImage | "https://..." | "/images/foo.jpg"} />

        │ generates a deterministic URL based on
        │ MD5(src + '|' + serialised params),
        │ rooted at the routeBase you supplied

  {routeBase}/{cacheKey}/{paramString}?src=...

        │ handled by createDitherRoute() which:
        │   1. Validates the params + cacheKey
        │   2. Enforces output / source limits
        │   3. For http(s) sources, applies the
        │      externalImages allowlist
        │   4. Reads the bytes (fs or fetch)
        │   5. Calls ditherImage(), returns
        │      lossless WebP with immutable
        │      Cache-Control

  next/image renders an <img> pointing at that URL,
  with `unoptimized` set so Next's image optimizer
  is bypassed and the byte-exact bitmap reaches
  the browser. CDNs cache it at the edge by URL.

Imported StaticImageData, same-origin string paths, and external http(s):// URLs all hit the same handler — it dispatches internally based on the shape of src.

What you don't need to think about

  • Cache invalidation for edited source images. Webpack hashes the filename on every source change, so editing portrait.jpg produces a new cacheKey and a fresh cache entry automatically.
  • Lossy re-encoding by next/image. <DitheredImageSSR /> sets unoptimized on the underlying <Image> by default — the optimizer is bypassed and the route handler's byte-exact lossless WebP reaches the browser unchanged. (See Interaction with next/image.)
  • Browser smoothing on retina / zoom. The component also sets style: { imageRendering: 'pixelated' } by default, so the browser doesn't anti-alias the bitmap when CSS scales it. Both defaults are overridable.
  • CORS for external URLs. The external URL route fetches the source server-side, so the browser never touches the external origin directly.

What you do need to think about

  • Pick a routeBase and stick with it. It's required, must start with /, and determines where your route handler file lives. /api/dithered is the recommended default — works identically on App Router and Pages Router.
  • External URLs are off by default. If you want <DitheredImageSSR src="https://..." /> to work, you have to opt in via externalImages in createDitherkit. See External URLs.
  • Pass width and height when src is a string URL. Unlike imported images, we can't read dimensions from a StaticImageData, so you have to tell next/image ahead of time. See the sizing model.
  • There is no automatic responsive srcset. Because the image optimizer is bypassed (see above), next/image does not generate device-pixel variants of the dithered output. Whatever width × height you pass is the bitmap the browser gets. If you need genuinely responsive delivery, use dk.getDitheredImageUrl(...) to build a <picture> with explicit source URLs at each size — see Building URLs without the component.
  • Install Sharp. It's a peer dependency: pnpm add sharp.
  • Understand the withDitherkit() helper. It adds ${routeBase}/** to your next/image allowlist, writes the env bridge that the runtime entries read, and a few other Next.js config tweaks. Read Route setup for the full story.

Example apps

On this page