runtime-image-transformer
v3.0.0
Published
Self-hosted, Sharp-powered runtime image transforms for server-side web frameworks (Next.js, TanStack Start, and anything else built on the Web `Request`/`Response` API).
Readme
runtime-image-transformer
Self-hosted, Sharp-powered runtime image transforms for server-side web frameworks (Next.js, TanStack Start, and anything else built on the Web Request/Response API).
If you want something closer to “Imgix/Cloudinary, but inside your own app,” this package gives you:
- A route handler that fetches an upstream image, runs a small set of transforms via Sharp, and caches results on disk. It's a plain
(req: Request) => Promise<Response>, so it drops into any framework whose routes speak the Web Fetch API. - A URL builder that creates transform URLs you can use from your app.
On Next.js specifically, this is an alternative to the built-in image optimization (
next/image+ the/_next/imageoptimizer), which is intentionally constrained and opinionated.
This tends to work best on managed app hosting where you run a Node runtime and can put a CDN in front:
- DigitalOcean App Platform (CDN-enabled)
- Render, Fly.io, Railway, Heroku
- AWS App Runner / ECS / EC2 (often fronted by CloudFront)
- Google Cloud Run (often fronted by Cloud CDN)
- Any setup fronted by Cloudflare / Fastly / etc.
Installation
The examples below use the Next.js App Router; see the collapsible section in step 2 for TanStack Start and other frameworks.
1) Install:
yarn add runtime-image-transformer sharp2) Add a route handler
Create a route handler at some path, for example: src/app/image/route.ts
// src/app/api/image/route.ts
import { createImageTransformRouteHandler } from "runtime-image-transformer/server";
import sharp from "sharp";
export const runtime = "nodejs";
const handler = createImageTransformRouteHandler({
// Required. The trusted origin that `source` paths are fetched from. Callers
// can only ever request paths under this origin, which is the SSRF protection.
sourceOrigin: process.env.IMAGE_SOURCE_ORIGIN ?? "https://images.example.com",
// Required. Sharp is a peer dependency — install it and pass the instance in.
sharp,
});
export const GET = handler;The handler is a plain (req: Request) => Promise<Response>. TanStack Start server routes hand you a context object, so unwrap request in a one-line adapter:
// src/routes/api/image.ts
import { createServerFileRoute } from "@tanstack/react-start/server";
import { createImageTransformRouteHandler } from "runtime-image-transformer/server";
import sharp from "sharp";
const handler = createImageTransformRouteHandler({
// Required. The trusted origin that `source` paths are fetched from.
sourceOrigin: process.env.IMAGE_SOURCE_ORIGIN ?? "https://images.example.com",
// Required. Sharp is a peer dependency — install it and pass the instance in.
sharp,
});
export const ServerRoute = createServerFileRoute().methods({
GET: ({ request }) => handler(request),
});The same pattern works for Hono, Remix, SvelteKit, and bare Request-based servers.
3) Create a URL builder module
Create a helper for example: src/lib/imageUrlBuilder.ts
import { createImageUrlBuilder } from "runtime-image-transformer";
export const imageUrlBuilder = createImageUrlBuilder({
// The path of the route above. A root-relative path is recommended: the
// builder then emits root-relative URLs, so there's no origin to configure at
// build time (and nothing to expose client-side). An absolute URL — e.g. a
// CDN-hosted route — also works.
apiRouteUrl: "/api/image",
});4) Start writing URLs:
Then build URLs like:
// src/components/MyImage.tsx
import { imageUrlBuilder } from "../lib/imageUrlBuilder";
export function MyImage() {
return (
<img
src={imageUrlBuilder({
// A path, resolved server-side against the handler's `sourceOrigin`.
source: "/cat.jpg",
fmt: "webp",
w: 800,
q: 80,
})}
/>
);
}API reference
createImageTransformRouteHandler(options)
Import from: runtime-image-transformer/server
Returns: (req: Request) => Promise<Response> (a Web Fetch handler — compatible with Next.js Route Handlers, TanStack Start server routes, etc.)
Options
sourceOrigin:string(required)- Description: The trusted, absolute origin that
sourcepaths are resolved and fetched against, e.g."https://images.example.com". This is the handler's SSRF protection: becausesourceis always a path joined to this fixed, server-side origin, callers can never make the server fetch an arbitrary host. Anysourcethat resolves off-origin (an absolute URL, or a protocol-relative//hostvalue) is rejected with400. - Note: This is intentionally not derived from the incoming request — the
Hostheader is attacker-controlled, so trusting it would reintroduce SSRF and enable cache poisoning. - Default: none (required; must be an
http(s)URL or the handler throws at construction)
- Description: The trusted, absolute origin that
sharp:typeof import("sharp").default(required)- Description: The Sharp factory (the module's default export). Sharp is a peer dependency, not bundled: install it in your app and pass the instance in (
import sharp from "sharp"). This lets your app pin Sharp's version and apply any global configuration (concurrency, SIMD, a custom/self-hosted build) before handing it over. - Default: none (required)
- Description: The Sharp factory (the module's default export). Sharp is a peer dependency, not bundled: install it in your app and pass the instance in (
cachePlugin:CachePlugin(optional)- Description: Cache backend for transformed images. A
CachePluginis an object with asyncread(key)/write(key, entry)methods, so you can back the cache with whatever store you like — the on-diskcreateFileSystemCachePlugin, or a shared/remote store such as S3-compatible object storage. SeecreateFileSystemCachePluginandCachePlugin. - Default:
undefined— server-side caching is disabled, and every request is transformed from the upstream source. OmitcachePluginon runtimes without a writable/persistent filesystem (e.g. workers), or when a CDN in front of the handler is expected to do the caching. PasscreateFileSystemCachePlugin()to opt into the on-disk cache.
- Description: Cache backend for transformed images. A
cacheControl:string(optional)- Description: Value for the response
Cache-Controlheader. - Default:
"public, max-age=31536000, immutable"
- Description: Value for the response
maxSourceBytes:number(optional)- Description: Maximum size, in bytes, of an upstream source image the handler will download, to guard against memory exhaustion. Enforced both against the upstream
Content-Length(rejected before downloading) and while streaming the body (so a missing or dishonestContent-Lengthcan't get around it). A source over the limit yields502. This bounds bytes buffered from the network;maxInputPixelsbounds decoded pixels. - Default:
20 * 1024 * 1024(20 MiB)
- Description: Maximum size, in bytes, of an upstream source image the handler will download, to guard against memory exhaustion. Enforced both against the upstream
maxInputPixels:number(optional)- Description: Maximum number of pixels (width × height) in the decoded source image (maps to Sharp's
limitInputPixels). Guards against a "pixel bomb" — a tiny compressed file that decodes to an enormous canvas, whichmaxSourceBytescan't catch. A source over the limit yields502. - Default:
3840 * 3840(~15 megapixels)
- Description: Maximum number of pixels (width × height) in the decoded source image (maps to Sharp's
fetchTimeoutMs:number(optional)- Description: Timeout for the upstream fetch. Bounds the whole upstream interaction — connect, response, and body download — so a slow or hanging
sourcecan't tie up the request indefinitely. On timeout the handler responds with502. - Default:
10_000(10 seconds)
- Description: Timeout for the upstream fetch. Bounds the whole upstream interaction — connect, response, and body download — so a slow or hanging
allowedFormats:Array<"preserve" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff">(optional)- Description: Output formats a request may ask for via
fmt. A request whose effective format (fmt, or"preserve"when omitted) isn't in this list is rejected with400. Use it to keep the served surface to the formats you actually want — e.g. modern codecs only, or dropping"preserve"to force every response to be re-encoded. - Default:
["preserve", "webp", "avif"]
- Description: Output formats a request may ask for via
Note: Upstream redirects are not followed (
redirect: "manual"). A redirect is treated as a failed fetch (502) so it can't be used to bounce the server offsourceOriginto an internal address.
Behavior notes
- Format defaulting: if
fmtis omitted in the URL, it defaults to"preserve". - Quality defaulting: if
qis omitted, the handler uses100. - Resize semantics: if
wand/orhis provided, the image is resized with:fit: "inside"(or the providedfit)withoutEnlargement: true
- Auto-orient: Sharp
rotate()is applied to respect EXIF orientation. - Caching: disabled by default. Pass a
cachePluginto enable it —createFileSystemCachePluginwrites to disk (so your runtime must have a writable filesystem), or supply a customCachePluginto cache elsewhere (e.g. shared object storage). With nocachePlugin, every request is transformed fresh — rely on a CDN in front of the handler to absorb load.
createFileSystemCachePlugin(options)
Import from: runtime-image-transformer/server
Returns: a CachePlugin that stores transformed images on the local filesystem (sharded by key prefix). Pass it as the handler's cachePlugin to enable on-disk caching.
Options
cacheDir:string(optional)- Description: Directory on disk where transformed images are cached.
- Default:
path.join(process.cwd(), ".transform-cache")
import {
createImageTransformRouteHandler,
createFileSystemCachePlugin,
} from "runtime-image-transformer/server";
import sharp from "sharp";
const handler = createImageTransformRouteHandler({
sourceOrigin: "https://images.example.com",
sharp,
cachePlugin: createFileSystemCachePlugin({ cacheDir: "/var/cache/images" }),
});CachePlugin
Import from: runtime-image-transformer/server (type-only)
The pluggable cache contract. Implement it to back the cache with any store — for example, shared S3-compatible object storage so a fleet of instances share one cache:
type CacheEntry = {
/** The encoded image bytes. */
body: Uint8Array;
/** The `Content-Type` to serve the bytes with. */
contentType: string;
};
type CachePlugin = {
/** Resolve to the entry on a hit, or `null` on a miss. */
read: (key: string) => Promise<CacheEntry | null>;
/** Persist an entry under `key`. */
write: (key: string, entry: CacheEntry) => Promise<void>;
};The handler derives an opaque, filesystem-safe key for each transform, calls read(key) before doing any work, and write(key, entry) after producing a result.
import {
createImageTransformRouteHandler,
type CachePlugin,
} from "runtime-image-transformer/server";
import sharp from "sharp";
// Example: an S3-compatible object-storage backed cache.
const s3Cache: CachePlugin = {
read: async (key) => {
const object = await bucket.get(key);
if (!object) return null;
return {
body: new Uint8Array(await object.arrayBuffer()),
contentType: object.httpMetadata.contentType,
};
},
write: async (key, { body, contentType }) => {
await bucket.put(key, body, { httpMetadata: { contentType } });
},
};
const handler = createImageTransformRouteHandler({
sourceOrigin: "https://images.example.com",
sharp,
cachePlugin: s3Cache,
});createImageUrlBuilder(options)
Import from: runtime-image-transformer
Returns: a function (config: TransformConfig) => string that builds a transform URL.
Options
apiRouteUrl:string(required)- Description: The transform route. Usually a root-relative path like
"/api/image"(the builder then emits root-relative URLs, so no origin is needed at build time). An absolute URL also works. - Default: none
- Description: The transform route. Usually a root-relative path like
Transform config + query parameters
The transform URL uses these query params:
source:string(required)- A path to the upstream image, e.g.
"/photos/cat.jpg". Resolved server-side against the handler'ssourceOrigin; values that resolve to a different origin are rejected.
- A path to the upstream image, e.g.
fmt:"preserve" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff"(optional)- If omitted, it defaults to
"preserve"(the upstream bytes are returned untouched). pngandgifignoreq(PNG is lossless; Sharp's GIF encoder has no quality option).- Which of these a request may actually use is gated by the handler's
allowedFormats(default["preserve", "webp", "avif"]); a disallowedfmtyields400.
- If omitted, it defaults to
w:number(optional)- Integer in
[1..16384]
- Integer in
h:number(optional)- Integer in
[1..16384]
- Integer in
fit:"cover" | "contain" | "fill" | "inside" | "outside"(optional)- Only used when resizing (
wand/orhis provided) - If omitted, it defaults to
"inside".
- Only used when resizing (
q:number(optional)- Integer in
[0..100] - Default:
100
- Integer in
CDN caching
This package is designed to sit behind a CDN, so responses are built to cache well:
Long-lived, immutable responses: the default
Cache-Controlispublic, max-age=31536000, immutable. A transform URL fully describes its output, so the result never changes for a given URL — configure it viacacheControlif you want a different policy.Explicit format, no
Vary: Accept: the output format is chosen by thefmtparam, not negotiated from theAcceptheader. This deliberately avoidsVary: Accept, which fragments cache entries and is handled inconsistently across CDNs.No-Vary-Search: every successful response carries aNo-Vary-Searchheader advertising the only params that affect the output:No-Vary-Search: key-order, params, except=("w" "h" "fit" "fmt" "q" "source")This tells caches to ignore param order and any unrelated params (e.g.
utm_*tracking), so?source=/cat.jpg&w=800&utm_campaign=xcollapses onto the same entry as?w=800&source=/cat.jpg. It mirrors how the handler computes its own cache key. Support is currently strongest in the browser HTTP cache and Speculation-Rules prefetch (Chromium); CDN support is still emerging.
Configuring the CDN cache key. Because No-Vary-Search isn't yet widely honored by CDNs, get the same normalization at the edge by keying only on the significant params (w, h, fit, fmt, q, source) and ignoring the rest:
- CloudFront — a Cache Policy with a query-string allowlist of those params.
- Cloudflare — Cache Rules / a custom Cache Key including only those params.
- Fastly — sort the query and strip unknown params in VCL.
If all your URLs come from createImageUrlBuilder, they're already canonical (fixed param order, no extras), so this mostly matters for hand-built or tracking-decorated URLs.
License
See LICENSE.md.
