@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/nextdoes 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-builtGEThandler usingNextRequest/NextResponse. Re-export withexport { GET } from '@ditherkit/next/route/app'.@ditherkit/next/route/pages— Pages Router. Pre-built default handler usingNextApiRequest/NextApiResponse. Re-export withexport { 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. Wrapsnext/imagewith ISR caching. Props, behaviour, the dimension contract.<DitheredPicture />— zero-JS responsive dithered images. Renders<picture>with multiple<source media>variants at different resolutions. IncludescomputeVariantshelper, art direction, crop/cover modes.- Route setup — the singleton pattern, both
router variants, and the legacy
createDitherkitfactory for multi-instance use cases. - StaticImageData — how imported images become deterministic, cacheable URLs via webpack hash extraction.
- External URLs — the other route, for
srcstrings 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.jpgproduces a newcacheKeyand a fresh cache entry automatically. - Lossy re-encoding by
next/image.<DitheredImageSSR />setsunoptimizedon the underlying<Image>by default — the optimizer is bypassed and the route handler's byte-exact lossless WebP reaches the browser unchanged. (See Interaction withnext/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
routeBaseand stick with it. It's required, must start with/, and determines where your route handler file lives./api/ditheredis 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 viaexternalImagesincreateDitherkit. See External URLs. - Pass
widthandheightwhensrcis a string URL. Unlike imported images, we can't read dimensions from aStaticImageData, so you have to tellnext/imageahead of time. See the sizing model. - There is no automatic responsive srcset. Because the image
optimizer is bypassed (see above),
next/imagedoes not generate device-pixel variants of the dithered output. Whateverwidth×heightyou pass is the bitmap the browser gets. If you need genuinely responsive delivery, usedk.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 yournext/imageallowlist, 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
apps/example-next— App Router, every integration point on a single page.apps/example-next-pages— Pages Router, feature-for-feature mirror of the App Router example.