Remix / React Router 7
No first-class server component yet. Workaround uses @ditherkit/core + sharp inside a loader.
Status: ⚠️ Workaround. There's no
@ditherkit/remixpackage. The workaround below assembles@ditherkit/core+ sharp inside a React Router 7 / Remixloaderto achieve the same cached-server-rendering effect. An example app is backlogged (apps/example-remix).
Remix (now React Router 7 Framework Mode) has a different model
from Next.js: there's no route handler primitive separate from the
loader. The idiomatic approach is to return a Response directly
from a loader or resource route, which is a better fit for
image-serving than you might think.
What works
@ditherkit/coreworks fine — it has no framework dependencies and runs anywhere.- Sharp works fine in Remix's Node runtime.
- Resource routes returning
Response— Remix's mechanism for routes that return non-HTML responses (like images). Cache-Controlheaders — Remix doesn't have ISR, but you can return standard HTTP cache headers and let the CDN handle caching.
What doesn't work
- No
<DitheredImageSSR />component — you'd call the dithering pipeline directly from your loader. - No bundled route handler helper — no
createDitherRoute(). - No deterministic cache key helper — you'd hash the source + params yourself or rely on URL-based CDN caching.
Recommended workaround
Create a resource route that takes the dither parameters in the URL and returns a dithered WebP:
// app/routes/dithered.$algorithm.$palette.$src.ts
import { type LoaderFunctionArgs } from 'react-router'
import sharp from 'sharp'
import {
// All 7 @ditherkit/core algorithms
floydSteinbergDither,
atkinsonDither,
jarvisJudiceNinkeDither,
stuckiDither,
burkesDither,
bayerDither,
thresholdDither,
grayscale,
type Color,
} from '@ditherkit/core'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
export async function loader({ params }: LoaderFunctionArgs) {
const { algorithm, palette: paletteString, src } = params
if (!algorithm || !paletteString || !src) {
return new Response('Missing params', { status: 400 })
}
// 1. Load the source image. For public/ assets:
const sourceBuffer = await readFile(
join(process.cwd(), 'public', decodeURIComponent(src))
)
// 2. Resize and extract raw RGB pixels with Sharp.
const { data, info } = await sharp(sourceBuffer)
.resize(800)
.raw()
.toBuffer({ resolveWithObject: true })
// 3. Convert RGB → RGBA (pad alpha).
const pixels = new Uint8ClampedArray(info.width * info.height * 4)
for (let i = 0, j = 0; i < data.length; i += 3, j += 4) {
pixels[j] = data[i]
pixels[j + 1] = data[i + 1]
pixels[j + 2] = data[i + 2]
pixels[j + 3] = 255
}
// 4. Parse the palette.
const palette: Color[] = paletteString.split(',').map((c) => {
const hex = c.startsWith('#') ? c.slice(1) : c
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
}
})
// 5. Grayscale (only for 2-colour palettes) + dither.
if (palette.length <= 2) {
grayscale(pixels)
}
switch (algorithm) {
case 'floyd-steinberg':
floydSteinbergDither(pixels, info.width, info.height, palette)
break
case 'atkinson':
atkinsonDither(pixels, info.width, info.height, palette)
break
case 'jarvis-judice-ninke':
jarvisJudiceNinkeDither(pixels, info.width, info.height, palette)
break
case 'stucki':
stuckiDither(pixels, info.width, info.height, palette)
break
case 'burkes':
burkesDither(pixels, info.width, info.height, palette)
break
case 'bayer':
bayerDither(pixels, info.width, info.height, palette)
break
case 'threshold':
thresholdDither(pixels, 128, palette)
break
default:
return new Response('Unknown algorithm', { status: 400 })
}
// 6. Re-encode as WebP.
const rgbOut = new Uint8Array((pixels.length / 4) * 3)
for (let i = 0, j = 0; i < pixels.length; i += 4, j += 3) {
rgbOut[j] = pixels[i]
rgbOut[j + 1] = pixels[i + 1]
rgbOut[j + 2] = pixels[i + 2]
}
const webp = await sharp(rgbOut, {
raw: { width: info.width, height: info.height, channels: 3 },
})
.webp({ quality: 90 })
.toBuffer()
// 7. Return with aggressive cache headers.
return new Response(new Uint8Array(webp), {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
}Then reference it from your components:
<img
src="/dithered/atkinson/000000,ffffff/portrait.jpg"
width={800}
height={600}
alt="Dithered portrait"
/>This is roughly what @ditherkit/next does internally, minus the
Next.js-specific niceties like ISR and the component wrapper.
Caching notes
- Remix doesn't have ISR, but the
Cache-Control: immutableheader tells CDNs and browsers to cache permanently. - To invalidate, change the URL. Include a hash of the source file or a version query string if you need cache busting.
- For dynamic sources (user uploads, remote URLs), return
Cache-Control: public, max-age=86400or similar finite TTL.
Example app
A Remix example app wrapping the workaround above is in progress.
Will there ever be @ditherkit/remix?
Possibly. It would be a thin wrapper around the pattern above —
createDitherRoute() that returns the loader + Response plumbing
for you. It hasn't been written because:
- The workaround above is small enough to paste into your own app
- Remix / React Router 7 is evolving rapidly and the right abstraction keeps moving
- Next.js is still the majority of our user interest
If you're using Remix in production and would benefit from a first-class package, open an issue and we'll reprioritise.
Related docs
@ditherkit/core— the algorithms you'll call directly- Image adjustments — grayscale, brightness, contrast
- Algorithms — the three dither algorithms