ditherkit
@ditherkit/next

<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

PropTypeDefaultDescription
srcStaticImageData | stringrequiredEither an imported image or an external URL.
altstringrequiredPassed to next/image.
widthnumber(see below)Output bitmap width.
heightnumber(see below)Output bitmap height.
pixelSizenumber1Cell size in output pixels.
algorithmDitherAlgorithm'Floyd-Steinberg'Dithering algorithm.
thresholdnumber128Threshold (0–255), only used for Threshold.
palettestring'#000000,#ffffff'Comma-separated hex colors.
brightnessnumber0Brightness adjustment (-100100).
contrastnumber0Contrast adjustment (-100100).
grayscalebooleanautoForce grayscale on/off.
classNamestringPassed to next/image.
styleReact.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.
sizesstringPassed 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.
prioritybooleanfalsePassed to next/image. Set this for above-the-fold LCP images.
unoptimizedbooleantrueWhether 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

  • StaticImageData source: pass at least one of width / 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): both width and height are 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 via fs.readFileSync (with an HTTP fallback in dev). StaticImageData ends up here too because webpack rewrites imported.src to /_next/static/media/....
  • Starts with http:// or https:// → external. Gated by the externalImages config.
  • 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/pixelSize and your routeBase in the cache key
  • Hands that URL to <Image> along with width, 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? unoptimized prevents the server from destroying the dither during URL transformation; pixelated prevents 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 } = dk
const 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:image meta tag, or pass it to next/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 no next/image machinery 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.

On this page