ditherkit
@ditherkit/next

External URLs

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.

Default behaviour: deny

Out of the box, passing an http(s):// string to <DitheredImageSSR /> throws a synchronous error at render time:

// lib/ditherkit.ts — no externalImages opt-in
export const dk = createDitherkit({ routeBase: '/api/dithered' })
// app/page.tsx
<DitheredImageSSR
  src="https://picsum.photos/seed/example/600/400"
  alt="…"
  width={600}
  height={400}
/>
Error: @ditherkit/next: external image URLs are disabled.
You passed src="https://picsum.photos/..." to <DitheredImageSSR />,
but createDitherkit was called with externalImages: false (the
default). 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.

Opting in

Three shapes, depending on how you'd rather declare the allowlist.

1. Explicit hostnames

The simplest case — you know exactly which hosts you fetch from.

// lib/ditherkit.ts
import { createDitherkit } from '@ditherkit/next/app'

export const dk = createDitherkit({
  routeBase: '/api/dithered',
  externalImages: {
    allowedHosts: ['picsum.photos', 'images.unsplash.com'],
  },
})

allowedHosts is exact-match against URL.hostname. No wildcards, no protocols, no ports. If you need any of those, use allowedPatterns.

2. Patterns (Next.js remotePatterns shape)

For wildcard subdomains, protocol restrictions, or port pinning, use the same shape Next.js uses for images.remotePatterns:

export const dk = createDitherkit({
  routeBase: '/api/dithered',
  externalImages: {
    allowedPatterns: [
      { protocol: 'https', hostname: '**.cdn.example.com' },
      { protocol: 'https', hostname: 'images.example.com', port: '' },
    ],
  },
})

Hostname glob semantics:

  • * 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.

3. Inherit from next.config.ts remotePatterns

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.ts
import { 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.

How it works

Assuming routeBase: '/api/dithered':

  1. The component generates a URL like:
    /api/dithered/{cacheKey}/atk_th128_pl000000ffffff_brp0_ctp0_w600_h400_px1_gsa?src=https%3A%2F%2Fpicsum.photos%2Fseed%2Fexample%2F600%2F400
    The cacheKey is MD5(src + '|' + paramString).slice(0, 12), so varying either the URL or the params produces a fresh cache entry.
  2. next/image requests that URL from your server.
  3. The unified route handler:
    • Validates the params slug.
    • Checks output dimensions against limits.maxOutputPixels.
    • Recomputes the cacheKey from src + paramString and rejects mismatches with a 400.
    • Classifies the source. For http(s)://:
      • Rejects with 403 if externalImages is disabled.
      • Rejects with 403 if the host isn't on the allowlist.
      • Otherwise fetches with a timeout and a streaming byte cap.
  4. Sharp + @ditherkit/core process the bytes.
  5. WebP output is returned with permanent cache headers in production (Cache-Control: max-age=31536000, immutable).

Everything after step 1 happens on your server. The browser never touches the external origin.

DOS limits

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: ... }):

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

export default withDitherkit({}, {
  routeBase: '/api/dithered',
  externalImages: { inheritRemotePatterns: true },
  limits: {
    maxOutputPixels: 16_000_000,    // ~4K square (default)
    maxSourceBytes: 10 * 1024 * 1024, // 10 MiB (default)
    fetchTimeoutMs: 5000,           // 5 s (default)
  },
})
LimitDefaultTrip → status
maxOutputPixels16,000,000413
maxSourceBytes (declared Content-Length)10 MiB413
maxSourceBytes (streamed body)10 MiB413
fetchTimeoutMs5000 ms504

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.

CORS is a non-issue

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.

Cache keys for external URLs

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.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[] }> }
) {
  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.

Performance

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.

On this page