ditherkit
@ditherkit/next

Route setup

Wire `<DitheredImageSSR />` into Next.js with a single `withDitherkit` call, the singleton component, and a one-line route handler — App Router or Pages Router.

<DitheredImageSSR /> generates URLs that point at a single route handler in your app. Wire that handler up once with withDitherkit in your next.config, and the same routeBase, externalImages allowlist, and DOS limits flow through the component, the route file, and Next.js's image optimizer automatically.

The singleton pattern

Three files. No factory, no shared lib module, no closure to thread between modules.

next.config.{ts,mjs} — configure once

// next.config.ts
import type { NextConfig } from 'next'
import { withDitherkit } from '@ditherkit/next/config'

const config: NextConfig = {
  // your existing config
}

export default withDitherkit(config, {
  routeBase: '/api/dithered',
  // optional — opt in to allow external image sources
  // externalImages: { inheritRemotePatterns: true },
})

routeBase is required and must start with /. It's the URL prefix under which the dithered-image route lives. The component emits URLs of the form ${routeBase}/{cacheKey}/{paramString}, and your route handler file must live at the matching path.

withDitherkit writes a build-time env bridge that the component and the route handler both read at runtime. Webpack's DefinePlugin inlines the values at build time so the lookup costs nothing on the hot path and survives Vercel cold starts.

Pages — render directly

// app/page.tsx — App Router
import { DitheredImageSSR } from '@ditherkit/next'
import portrait from '@/assets/portrait.jpg'

export default function Page() {
  return (
    <DitheredImageSSR
      src={portrait}
      alt="Server-rendered dithered portrait"
      algorithm="Atkinson"
      priority
    />
  )
}

The component is imported directly from @ditherkit/next — identical for App Router and Pages Router. There is no lib/ factory file to create, no dk instance to thread, no router- specific subpath to remember at the call site.

Route handler — one re-export

App Router

// app/api/dithered/[cacheKey]/[...params]/route.ts
export { GET } from '@ditherkit/next/route/app'

Pages Router

// pages/api/dithered/[cacheKey]/[...params].ts
export { default } from '@ditherkit/next/route/pages'

The path under app/ (App Router) or pages/ (Pages Router) must match the routeBase you chose. The same handler serves both imported StaticImageData sources and string sources (same-origin local paths and — when externalImages is enabled — http(s):// URLs that match the allowlist). It classifies the ?src= value internally and dispatches.

That's the entire setup. No lib/ditherkit.ts, no lib/ditherkit-routes.ts, no factory function, no dk instance.

What withDitherkit adds to your NextConfig

export default withDitherkit({ /* your config */ }, { routeBase: '/api/dithered' })

merges these settings on top of the user config:

  • images.localPatterns — adds ${routeBase}/** so next/image accepts URLs at your route base. Without this, <Image> rejects the dithered URL with a "URL is not allowed" error.
  • env.DITHERKIT_ROUTE_BASE_V1 — the env-var bridge for routeBase. Read by the component and the route handler at runtime; inlined at build time by Webpack's DefinePlugin.
  • env.DITHERKIT_EXTERNAL_IMAGES_CONFIG_V1 — the env-var bridge for the resolved external-images allowlist (including any hosts inherited from images.remotePatterns).
  • env.DITHERKIT_LIMITS_V1 — the env-var bridge for any DOS limits you overrode under withDitherkit({ limits: ... }). Only emitted when at least one limit is set; otherwise the runtime applies its defaults.
  • turbopack.ignoreIssue — suppresses a noisy but harmless Turbopack warning about next.config.* file tracing in route handlers.

If you're already passing custom images.remotePatterns or turbopack config, withDitherkit merges with them rather than replacing — your existing settings are preserved.

Vercel deployment protection

On Vercel, _next/static/ files live on the CDN, not inside the serverless function container. The route handler reads them via an internal HTTP fetch. On preview deployments with deployment protection enabled, that fetch gets a 401 unless you enable Protection Bypass for Automation in your Vercel project's Deployment Protection settings. This makes VERCEL_AUTOMATION_BYPASS_SECRET available as a system env var, and the route handler sends it automatically.

See Troubleshooting → Vercel preview deployments: 401 for the step-by-step fix.

Multi-instance: the createDitherkit factory

The singleton pattern handles the common case of one dithered-image route per app. If you genuinely need two route bases in the same app (e.g. a public route and an admin-only route with different allowlists), the createDitherkit factory is the escape hatch:

// lib/ditherkit-admin.ts
import { createDitherkit } from '@ditherkit/next/app'
//   or '@ditherkit/next/pages' for Pages Router

export const adminDk = createDitherkit({
  routeBase: '/admin/api/dithered',
  externalImages: { allowedHosts: ['internal.example'] },
})

export const { DitheredImageSSR: AdminDitheredImageSSR } = adminDk
// lib/ditherkit-admin-routes.ts — server-only
import { createDitherkitRoutes } from '@ditherkit/next/app/server'
import { adminDk } from './ditherkit-admin'

export const { createDitherRoute } = createDitherkitRoutes(adminDk)
// app/admin/api/dithered/[cacheKey]/[...params]/route.ts
import { createDitherRoute } from '@/lib/ditherkit-admin-routes'
export const GET = createDitherRoute()

The split between @ditherkit/next/app (client-safe) and @ditherkit/next/app/server (server-only) is required here because the route handler factory transitively imports Sharp, which can't be bundled for the browser. The singleton pattern avoids the split entirely by reading configuration from the env bridge instead of a JavaScript closure.

createDitherkit doesn't support inheritRemotePatterns — only withDitherkit (in @ditherkit/next/config) has access to the host's nextConfig.images.remotePatterns array. Multi-instance secondary routes have to declare their hosts explicitly via allowedHosts or allowedPatterns.

Mixing the singleton and the factory

The two patterns coexist without interfering:

  • The singleton component (<DitheredImageSSR /> from @ditherkit/next) reads its routeBase, externalImages allowlist, and limits from the env bridge that withDitherkit writes in next.config.
  • A factory-created component (adminDk.DitheredImageSSR) reads everything from the closure created by createDitherkit({ ... }). It never touches the env bridge.

So you can call withDitherkit({ routeBase: '/api/dithered' }) in next.config for the primary route, then create a second createDitherkit({ routeBase: '/admin/api/dithered', ... }) instance in a lib file for the admin route, and the two will emit URLs at their respective bases without any cross-talk. Each route file points at its own handler factory: the primary uses the pre-built @ditherkit/next/route/app re-export, the admin uses createDitherkitRoutes(adminDk) from a server-only lib file.

The one thing to watch: the admin route's images.localPatterns is not set up automatically — withDitherkit only writes it for the routeBase you pass it. You have to extend next.config manually for the admin path:

// next.config.ts
import { withDitherkit } from '@ditherkit/next/config'

export default withDitherkit(
  {
    images: {
      localPatterns: [
        // The primary `/api/dithered/**` is added by withDitherkit
        // automatically. Add the secondary admin path here:
        { pathname: '/admin/api/dithered/**' },
      ],
    },
  },
  { routeBase: '/api/dithered' }
)

Customising the route handler

@ditherkit/next/route/app and @ditherkit/next/route/pages are plain re-exports of a pre-built handler, so you can wrap them inline if you need custom middleware:

// app/api/dithered/[cacheKey]/[...params]/route.ts
import { GET as ditherGet } from '@ditherkit/next/route/app'
import { type NextRequest } from 'next/server'

export async function GET(
  request: NextRequest,
  context: { params: Promise<{ cacheKey: string; params: string[] }> }
) {
  // pre-flight checks, rate limiting, auth, custom logging…
  if (!request.headers.get('user-agent')?.includes('Mozilla')) {
    return new Response('Forbidden', { status: 403 })
  }

  return ditherGet(request, context)
}

Same pattern for Pages Router: import the default export from @ditherkit/next/route/pages, wrap it in a (req, res) => void handler.

You usually do not need to add a hand-rolled allowlist on top of this — that's what the externalImages config is for. See External URLs.

Caching behaviour

Development: every request is processed fresh. The route handler sets Cache-Control: no-cache so source image changes show up immediately without clearing any cache.

Production: the handler sets Cache-Control: public, max-age=31536000, immutable directly on the response. CDNs and browsers cache the bytes permanently. Cache keys are deterministic (based on MD5(src + '|' + paramString)), so editing a source image (or any dither parameter) produces a new cache key automatically and the old entry ages out naturally. You never need to manually invalidate.

The cache key in the URL path is also bound to the ?src= query at request time — if they don't match the handler returns a 400. This closes the cache-flooding angle where an attacker could mint unbounded distinct cache entries by varying the path independently of the query.

You don't need export const revalidate on the route — caching is driven entirely by Cache-Control headers. Adding revalidate is also incompatible with Next.js's cacheComponents: true mode and will fail the build.

See ISR caching for the full story on how cache keys are generated.

On this page