Server-side dithering for images hosted elsewhere — secure by default, opt in via config, share an allowlist with next/image's remotePatterns.
When you pass a string src like https://picsum.photos/600/400 to
<DitheredImageSSR />, the component routes through the same unified
handler as imported images — ${routeBase}/{cacheKey}/{paramString}
— and the handler fetches the source from the remote origin
server-side. CORS isn't a concern; the browser never touches the
external host.
But there's a catch: fetching arbitrary attacker-controlled URLs
on your server is a serious vulnerability (SSRF, image-proxy
abuse, bandwidth exhaustion). So @ditherkit/next is secure by
default: external URLs are disabled until you explicitly opt in.
Error: @ditherkit/next: external image URLs are disabled.You passed src="https://picsum.photos/..." to <DitheredImageSSR />,but createDitherkit was called with externalImages: false (thedefault). To allow external sources, opt in explicitly: createDitherkit({ routeBase: '/api/dithered', externalImages: { allowedHosts: ['example.com'] }, })
The error fires before the component renders any HTML, so:
next dev shows the dev overlay with the full message and link.
next build fails the static render of pre-rendered pages.
next start returns a 500 to the offending request.
A request crafted by hand against the route at
/api/dithered/{cacheKey}/{paramString}?src=https%3A%2F%2F… is
also rejected — with a 403, not just the render-time throw —
because the route handler enforces the same allowlist on every
request.
Imported StaticImageData and same-origin local string paths
(/images/foo.jpg) are not affected by externalImages — they
bypass the allowlist entirely because they're not external sources.
* matches a single DNS label (*.example.com matches
foo.example.com but not foo.bar.example.com)
** matches one or more labels (**.example.com matches both)
The matcher in v1 enforces protocol, hostname, and port.
pathname is accepted for parity with next/image but is not
checked — pathname matching does not provide a meaningful security
boundary because an attacker who controls a hostname controls every
path under it.
If you already declare images.remotePatterns for next/image's
own optimizer, you probably don't want to maintain a parallel list
for ditherkit. Opt in to inheriting them — one call in next.config
covers everything:
// next.config.tsimport { withDitherkit } from '@ditherkit/next/config'export default withDitherkit( { images: { // Single source of truth — both next/image and ditherkit // consume this list. remotePatterns: [ { hostname: 'picsum.photos' }, { hostname: '**.cdn.example.com' }, ], }, }, { routeBase: '/api/dithered', externalImages: { inheritRemotePatterns: true }, })
withDitherkit reads images.remotePatterns at config-evaluation
time and writes them into the bundle through an env-var bridge that
both the component and the route handler pick up at runtime. This
works under both next dev and next start (and on Vercel
serverless), because Next.js inlines the bridge value via
NextConfig.env at build time.
You can combine inheritRemotePatterns with allowedHosts and
allowedPatterns — they all get merged.
If you forget to call withDitherkit in your next.config, the
env bridge never gets written, the runtime component throws on
first render with a clear "routeBase is not configured" error,
and the route handler falls back to deny-all. The safest pattern
is "always call withDitherkit in your config file", which the
example apps demonstrate.
The route handler applies a few default DOS guards. They're
enforced regardless of which externalImages shape you use, and you
can override any of them via withDitherkit({ ..., limits: ... }):
The maxSourceBytes cap is enforced both upfront via
Content-Length and via a running byte counter on the response
body, so a server lying about its content length still gets
truncated.
These guards are not a substitute for rate limiting, which depends
on infrastructure (Vercel KV, Upstash, Redis, in-memory). If your
endpoint is exposed to the internet you should add rate limiting at
the edge — see your platform's docs.
Because the server fetches the image, CORS headers on the source
origin don't matter. If your server can reach the URL, the image
can be dithered. Contrast with @ditherkit/react, which runs in the
browser and needs the source to send Access-Control-Allow-Origin.
This is one of the main reasons to reach for @ditherkit/next over
@ditherkit/react when dealing with arbitrary third-party images.
The cache key is MD5(src + '|' + paramString).slice(0, 12). So:
Same URL + same params → same cache key → cache hit
Different URL or different params → different cache key → fresh
render
External URLs don't have content hashes like imported images do, so
the cache won't automatically refresh if the remote image changes
under the same URL. If your external source can change in place,
wrap the handler and override the Cache-Control response header
with a finite max-age:
// app/api/dithered/[cacheKey]/[...params]/route.tsimport { 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[] }> }) { const response = await ditherGet(request, context) if (process.env.NODE_ENV === 'production') { response.headers.set('Cache-Control', 'public, max-age=3600') } return response}
For stable, content-addressed remote URLs (like S3 with versioned
filenames), the default permanent cache is fine — the URL changes
when the content changes.
The server has to fetch the source image on the first request per
cache key. That can be slow for large remote images. Subsequent
requests for the same URL + params hit the cache and return
instantly.
If you need to warm the cache, render the component on a page that
gets crawled (sitemap, any static page) — the first next/image
request triggers the fetch and populates the cache for everyone
else.