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}/**sonext/imageaccepts 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 forrouteBase. 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 fromimages.remotePatterns).env.DITHERKIT_LIMITS_V1— the env-var bridge for any DOS limits you overrode underwithDitherkit({ 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 aboutnext.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 itsrouteBase, externalImages allowlist, and limits from the env bridge thatwithDitherkitwrites innext.config. - A factory-created component (
adminDk.DitheredImageSSR) reads everything from the closure created bycreateDitherkit({ ... }). 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.