ditherkit
Integrations

Remix / React Router 7

No first-class server component yet. Workaround uses @ditherkit/core + sharp inside a loader.

Status: ⚠️ Workaround. There's no @ditherkit/remix package. The workaround below assembles @ditherkit/core + sharp inside a React Router 7 / Remix loader to 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/core works 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-Control headers — 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.

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: immutable header 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=86400 or 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:

  1. The workaround above is small enough to paste into your own app
  2. Remix / React Router 7 is evolving rapidly and the right abstraction keeps moving
  3. 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.

On this page