<DitheredImageSSR />
The server-rendered component. Renders a dithered image through next/image with ISR caching.
A React component that emits a dithered image via next/image. The
actual processing happens in the paired route handler (see
Route setup) — this component's job is to
generate the right URL and pass it on.
<DitheredImageSSR /> is created by the factory, not imported
directly from the package. The factory binds the component to your
chosen routeBase so every URL it emits points at your route
handlers:
// next.config.ts — configure once
import { withDitherkit } from '@ditherkit/next/config'
export default withDitherkit({}, { routeBase: '/api/dithered' })// anywhere in your app
import { DitheredImageSSR } from '@ditherkit/next'If you've never set ditherkit up before, start with Route setup.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | StaticImageData | string | required | Either an imported image or an external URL. |
alt | string | required | Passed to next/image. |
width | number | (see below) | Output bitmap width. |
height | number | (see below) | Output bitmap height. |
pixelSize | number | 1 | Cell size in output pixels. |
algorithm | DitherAlgorithm | 'Floyd-Steinberg' | Dithering algorithm. |
threshold | number | 128 | Threshold (0–255), only used for Threshold. |
palette | string | '#000000,#ffffff' | Comma-separated hex colors. |
brightness | number | 0 | Brightness adjustment (-100–100). |
contrast | number | 0 | Contrast adjustment (-100–100). |
grayscale | boolean | auto | Force grayscale on/off. |
className | string | — | Passed to next/image. |
style | React.CSSProperties | { imageRendering: 'pixelated' } | Inline styles passed to next/image. Merged on top of the default, which is imageRendering: 'pixelated' — keeps the bitmap crisp under retina / zoom / responsive scaling. Pass style={{ imageRendering: 'auto' }} to opt out. |
sizes | string | — | Passed straight to next/image. Note: with unoptimized on (the default), Next does not generate an optimizer-driven srcset, so this prop only sets the <img sizes> attribute for layout purposes — it does not produce alternative bitmap sizes. See Building URLs without the component for how to render true responsive variants. |
priority | boolean | false | Passed to next/image. Set this for above-the-fold LCP images. |
unoptimized | boolean | true | Whether to bypass next/image's built-in optimizer. Defaults to true because the optimizer resamples and re-encodes lossily, which destroys the byte-exact dither output. Set to false only if you genuinely want a downscaled / re-encoded derivative — e.g. an art-directed placeholder. |
The component does not take a routeBase prop — that comes from
the factory call site. If you need different routeBases in different
parts of an app, call createDitherkit multiple times with
different values and import the right component from the right
factory instance.
Sizing model
Same model as <DitheredImage /> — the
caller declares the output bitmap size (width × height) and
the cell size in output pixels (pixelSize). Sharp downsamples
the source to output / pixelSize, dithers there, then nearest-
neighbour upscales the result back to width × height for the cached
WebP. The next/image element reserves space at exactly that size,
so there is no layout shift.
Width and height rules
StaticImageDatasource: pass at least one ofwidth/height. The missing dimension is derived from the source's intrinsic aspect ratio (which webpack provides for free at import time). Pass neither and you get the source's intrinsic dimensions.- String
src(external URL or static path): bothwidthandheightare required. There's no metadata to derive from at render time, so the caller has to declare them.
Cell-size snapping
Same rule as the React component: dimensions are snapped down to
the nearest multiple of pixelSize. A request for 1000×750 with
pixelSize=3 becomes a 999×750 output (333×250 process
resolution × 3). The snapped values are what end up in the cache key
and the URL, so two identical configurations always hit the same
cache entry.
In dev builds, snapping logs a warning when it changes a value you supplied explicitly:
[DitheredImageSSR] requested 1000×750 with pixelSize=3 → snapped to 999×750
(process 333×250). Output dimensions are always rounded down to a
multiple of pixelSize.Examples
Imported image, width-only
import portrait from '@/assets/portrait.jpg' // 1200 × 1800
<DitheredImageSSR src={portrait} alt="..." width={800} />
// Height auto-derived from source aspect → 1200.
// Output: 800 × 1200.Imported image, with chunky cells
<DitheredImageSSR
src={portrait}
alt="..."
width={1200}
height={1800}
pixelSize={4}
/>
// Sharp resizes to 300 × 450, dithers there, upscales back to 1200 × 1800
// with nearest-neighbour. Each dither cell is exactly 4 × 4 px.External URL — both dimensions required
<DitheredImageSSR
src="https://picsum.photos/400/300"
alt="..."
width={400}
height={300}
/>Adjusting tonal range before dithering
<DitheredImageSSR
src={portrait}
alt="High-contrast portrait"
width={1200}
algorithm="Atkinson"
brightness={-10}
contrast={30}
/>Brightness and contrast are applied before the grayscale + dither steps, so they shape the tonal range the algorithm gets to work with.
Which route handles which src?
There's only one route. Imported StaticImageData, same-origin
local string paths, and http(s):// URLs all flow through the
same handler:
${routeBase}/{cacheKey}/{paramString}?src=...The handler classifies the ?src= value at request time:
- Starts with
/→ same-origin local. Read off disk viafs.readFileSync(with an HTTP fallback in dev). StaticImageData ends up here too because webpack rewritesimported.srcto/_next/static/media/.... - Starts with
http://orhttps://→ external. Gated by theexternalImagesconfig. - Anything else → 400.
You need exactly one route handler wired up, regardless of which
kinds of src you use. See Route setup.
External http(s):// sources are disabled by default —
attempting to render one without opting in throws a synchronous
error at render time. See External URLs
for the opt-in story.
Interaction with next/image
<DitheredImageSSR /> is a thin wrapper around <Image> from
next/image. It:
- Computes a deterministic URL for the dithered output, including
the canonical (snapped)
width/height/pixelSizeand yourrouteBasein the cache key - Hands that URL to
<Image>along withwidth,height,alt,sizes,priority,className, and the two pixel-preservation defaults below
The generated URL is same-origin (it hits your own route
handler), so you don't need to add anything to your next.config.*
remotePatterns. However, you do need withDitherkit() to
allow the internal ${routeBase}/** path — see Route
setup.
Pixel-preservation defaults
Two <Image> props are set by default to keep the dither byte-exact
all the way to the user's screen:
unoptimized: true
next/image normally rewrites every image URL through the Next
image optimizer at /_next/image?url=...&w=...&q=75, which
resamples and lossily re-encodes the response. For a dithered
1-bit (or few-colour) bitmap that's catastrophic — the optimizer
treats the sharp dither pattern as noise and smooths it away. By
the time the bytes reach the browser, the carefully-placed
high-frequency dither has been destroyed.
Setting unoptimized makes <Image> render an <img> whose src
points directly at your route handler, so the lossless WebP
bytes the route returned reach the browser unchanged. This is the
default and should almost never be flipped off.
The escape hatch — unoptimized={false} — exists for the rare case
where you genuinely want a downscaled, re-encoded derivative (an
art-directed placeholder, a deliberately-blurry LQIP). If you set
it, expect the dither to be smoothed.
style: { imageRendering: 'pixelated' }
Even with unoptimized set, browsers will resample the bitmap at
paint time whenever CSS stretches it — most commonly on retina
displays (where 1 CSS px = 2+ device px), under user zoom, or in
responsive layouts where the rendered width doesn't match the
intrinsic width. The default resampling kernel is bilinear, which
smooths the dither.
image-rendering: pixelated switches the browser to nearest-
neighbour upscaling, so each source pixel becomes a square block
of device pixels and the dither pattern survives intact.
The default is merged with any user-supplied style via spread
order, so passing style={{ imageRendering: 'auto' }} is a clean
opt-out and style={{ border: '1px solid red' }} adds a border
without losing the pixelated rendering.
Why both?
unoptimizedprevents the server from destroying the dither during URL transformation;pixelatedprevents the browser from destroying it during paint. They're orthogonal — flipping either off reintroduces smoothing at a different stage of the pipeline.
Building URLs without the component
The factory also returns a getDitheredImageUrl(props) helper —
the same URL <DitheredImageSSR /> would emit, without rendering
anything:
// lib/ditherkit.ts
export const dk = createDitherkit({ routeBase: '/api/dithered' })
export const { DitheredImageSSR, getDitheredImageUrl } = dkconst url = getDitheredImageUrl({
src: portrait, // StaticImageData or string URL
width: 800,
pixelSize: 2,
algorithm: 'Atkinson',
})
// → "/api/dithered/abc123.../atk_th128_pl000000ffffff_..._w800_h1200_px2_gsa?src=..."It accepts every prop the component does except the
rendering-only ones (alt, className, style, sizes,
priority, unoptimized). Critically, it runs the same
dimension resolution as the component — StaticImageData
aspect-ratio derivation, pixelSize snapping — so the URL it
returns collides on the exact same ISR cache entry the component
would emit.
When to use it
- OG images — feed the URL into an
og:imagemeta tag, or pass it tonext/og(Satori) which can't render arbitrary React components but can fetch a URL. <picture>with explicit responsive sources — render a<picture>with multiple<source srcset>entries pointing at distinct widths. For a built-in component that handles this automatically, see<DitheredPicture />.- CSS
background-image— the URL is just a string; drop it in any CSS context. - Plain
<img>for diagnostics — useful for verifying that the route handler is producing the bitmap you expect, with nonext/imagemachinery in between.
Responsive <picture> example
import { getDitheredImageUrl } from '@ditherkit/next'
import portrait from '@/assets/portrait.jpg'
const small = getDitheredImageUrl({ src: portrait, width: 480 })
const medium = getDitheredImageUrl({ src: portrait, width: 960 })
const large = getDitheredImageUrl({ src: portrait, width: 1920 })
export function ResponsivePortrait() {
return (
<picture>
<source media="(max-width: 600px)" srcSet={small} />
<source media="(max-width: 1200px)" srcSet={medium} />
<img
src={large}
alt="A portrait, dithered on the server"
style={{ imageRendering: 'pixelated' }}
/>
</picture>
)
}Each variant is its own ISR cache entry, processed once and served forever — same caching guarantees as the component.
App Router vs Pages Router
The component is identical between the two routers — same function, same JSX, same URL output, same import:
import { DitheredImageSSR } from '@ditherkit/next'The only setup difference is which route handler subpath your route
file re-exports from (@ditherkit/next/route/app vs
@ditherkit/next/route/pages) — see Route setup.
In App Router the component renders inside a server component and
emits an <img> in the resulting HTML. In Pages Router it renders
during SSR/SSG, then re-renders during client hydration with the
same props (and therefore the same URL). Either way, the user sees
the dithered image with no layout shift, no client-side processing,
and no flash of unprocessed content.