My New On-Demand Image Resizing Pipeline for Eleventy
Don't get me wrong: I love using Eleventy. I am benefiting so much of its little and big conveniences and generally agree with its philosophy: Stability over features. Predictability over flashiness.[1] That being said, I grew increasingly grumpy about the long build times of my blog: 20 minutes and more.
The longer I have to deal with 20 min+ builds for my website (it just has lots of images - served remotely from an object storage - by this point that eleventy-fetch and elventy-img have to go through) the more I am unsatisfied with the setup I have come up with for myself.
In theory, images should be downloaded/resized once. In practice cache invalidation and build pipeline limitations (and me not having the time to solve it properly - but I do have time to wait 20 minutes per deploy? Huh.) mean that instead of waiting around 3 mins I wait way longer.
I love #eleventy, but I do sometimes feel the trade-off of using an ssg over a dynamic blog solution like #ghost…
I recently went my own way when it comes to how the images for this blog are being generated. I had heard about Images by CoolLabs, but didn't want to pay another 5€ per month and I also had the additional challenge that I liked how Eleventy-img replaced simple img elements with picture elements with srcsets and everything!
So I built my own resizing api! This uses sharp - which is used by Eleventy-img internally also. I had a good starting point for doing this, because my images are served from an s3 compatible bucket through an API-Endpoint. So any GET request hitting anything in /media, like:
https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg
actually gets the resource - if it exists - from S3 without exposing the S3 bucket itself. With Eleventy-img that means that the images were fetched on build, transformed three times - 1400 (for social media), 640 (for desktop), 320 (for mobile) and then cached. I had written about this approach: How To Use eleventy-img (To Optmize Images In Eleventy) With Caching (To Keep Build Times Low) On Cloudflare Pages (Which Can't Cache Optimized Images Out Of The Box).
As I said, Eleventy-Img turns these image variants into a full-fledged picture element, which is something I wanted to keep. Well, it turns out, you totally can use only that part of Eleventy-img! The generateHTML function is exported and you can totally use it!
But first here's the little API[2]:
import { Hono } from "hono";
import { Env } from "../env";
import sharp from "sharp";
import { createHash } from "crypto";
import { mkdir } from "fs/promises";
import { existsSync } from "fs";
import { join } from "path";
type Variables = {
env: Env;
};
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
// Cache directory for optimized images
const CACHE_DIR = process.env.IMAGE_CACHE_DIR || ".cache/images";
// Supported image formats
const SUPPORTED_FORMATS = ["webp", "jpeg", "png", "avif"] as const;
type ImageFormat = (typeof SUPPORTED_FORMATS)[number];
// Allowed widths (prevent abuse)
const ALLOWED_WIDTHS = [320, 640, 768, 1024, 1400, 1920];
/**
* Generate cache key for resized image
*/
function getCacheKey(path: string, width?: number, format?: string): string {
const hash = createHash("md5")
.update(`${path}-${width || "original"}-${format || "original"}`)
.digest("hex");
return hash;
}
/**
* Get cache file path
*/
function getCachePath(cacheKey: string, format: string): string {
return join(CACHE_DIR, `${cacheKey}.${format}`);
}
/**
* Ensure cache directory exists
*/
async function ensureCacheDir() {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
}
/**
* Check if image should be processed
*/
function isImagePath(path: string): boolean {
return /\.(jpe?g|png|webp|gif|avif)$/i.test(path);
}
/**
* Get content type for format
*/
function getContentType(format: string): string {
const types: Record<string, string> = {
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
png: "image/png",
avif: "image/avif",
gif: "image/gif",
};
return types[format.toLowerCase()] || "application/octet-stream";
}
/**
* Serve media files from storage with on-demand image optimization
* Supports query parameters: ?w=640&format=webp
*/
app.get("/*", async (c) => {
const env = c.get("env") as Env;
const storage = env.storage;
const bucket = env.storageBucket;
// Extract path from URL (remove /media/ prefix)
const path = c.req.path.replace("/media/", "");
// Parse query parameters for image optimization
const widthParam = c.req.query("w");
const formatParam = c.req.query("format") as ImageFormat | undefined;
// Validate width parameter
const requestedWidth = widthParam ? parseInt(widthParam) : undefined;
const width =
requestedWidth && ALLOWED_WIDTHS.includes(requestedWidth)
? requestedWidth
: undefined;
// Validate format parameter
const format =
formatParam && SUPPORTED_FORMATS.includes(formatParam)
? formatParam
: undefined;
// If no optimization requested or not an image, serve original
if ((!width && !format) || !isImagePath(path)) {
try {
const stream = await storage.getObject(bucket, path);
const stat = await storage.statObject(bucket, path);
const contentType =
stat.metaData?.["content-type"] || "application/octet-stream";
return new Response(stream as any, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error: any) {
if (error.code === "NoSuchKey" || error.code === "NotFound") {
return c.text("Not found", 404);
}
console.error("Media fetch error:", error);
return c.text("Internal server error", 500);
}
}
// Generate cache key and path
const outputFormat = format || "webp"; // Default to webp
const cacheKey = getCacheKey(path, width, outputFormat);
const cachePath = getCachePath(cacheKey, outputFormat);
// Check if cached version exists
if (existsSync(cachePath)) {
const fs = await import("fs/promises");
const cachedImage = await fs.readFile(cachePath);
return new Response(cachedImage, {
headers: {
"Content-Type": getContentType(outputFormat),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Image-Cache": "hit",
},
});
}
// Fetch original image from storage
try {
const stream = await storage.getObject(bucket, path);
// Convert stream to buffer
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Process image with sharp
let transformer = sharp(buffer);
// Resize if width specified
if (width) {
transformer = transformer.resize(width, undefined, {
withoutEnlargement: true,
fit: "inside",
});
}
// Convert format
switch (outputFormat) {
case "webp":
transformer = transformer.webp({ quality: 85 });
break;
case "jpeg":
transformer = transformer.jpeg({ quality: 85, progressive: true });
break;
case "png":
transformer = transformer.png({ compressionLevel: 9 });
break;
case "avif":
transformer = transformer.avif({ quality: 80 });
break;
}
const optimizedBuffer = await transformer.toBuffer();
// Save to cache
await ensureCacheDir();
const fs = await import("fs/promises");
await fs.writeFile(cachePath, optimizedBuffer);
// Return optimized image
return new Response(optimizedBuffer, {
headers: {
"Content-Type": getContentType(outputFormat),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Image-Cache": "miss",
},
});
} catch (error: any) {
if (error.code === "NoSuchKey" || error.code === "NotFound") {
return c.text("Not found", 404);
}
console.error("Image optimization error:", error);
return c.text("Internal server error", 500);
}
});
export default app;
So we accept a GET and a couple of query params. Example:
https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=320&format=jpeg
This specifies a width of 320 and a format of jpeg. We calculate a hash so we don't need to do the work twice and if we haven't done so already resize/convert the image before responding with it. We use minio-js to connect to the object storage and as I said sharp to do the resizing.
The fun part is that we can use this API now to create picture elements on build that are made functional by this API at runtime! For that we have to add some configuration to eleventy.config.js:
// Import generateHTML from eleventy-img
const { generateHTML } = await import("@11ty/eleventy-img");
// Transform HTML to add responsive image attributes using eleventy-img's generateHTML
eleventyConfig.addTransform("vpsImageOptimization", function (content) {
// Only process HTML files
if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) {
return content;
}
const baseUrl = "https://blog.martin-haehnel.de";
// Transform img tags pointing to https://blog.martin-haehnel.de/media/uploads/ to include srcset
return content.replace(
/<img\s+([^>]*?)src=["']([^"']*\/media\/uploads\/[^"'?]+)["']([^>]*?)>/gi,
(match, _beforeSrc, src) => {
// Extract the media path (remove any existing query params or base URL)
const mediaPath = src.replace(/^https:\/\/[^/]+/, "").replace(/\?.*$/, "");
// Extract alt text
const altMatch = match.match(/alt=["']([^"']*)["']/i);
const alt = altMatch ? altMatch[1] : "";
// This mimics the structure returned by the Image() function
const metadata = {
webp: [
{
url: `${baseUrl}${mediaPath}?w=320&format=webp`,
srcset: `${baseUrl}${mediaPath}?w=320&format=webp 320w`,
width: 320,
sourceType: "image/webp",
},
{
url: `${baseUrl}${mediaPath}?w=640&format=webp`,
srcset: `${baseUrl}${mediaPath}?w=640&format=webp 640w`,
width: 640,
sourceType: "image/webp",
},
{
url: `${baseUrl}${mediaPath}?w=1400&format=webp`,
srcset: `${baseUrl}${mediaPath}?w=1400&format=webp 1400w`,
width: 1400,
sourceType: "image/webp",
},
],
jpeg: [
{
url: `${baseUrl}${mediaPath}?w=320&format=jpeg`,
srcset: `${baseUrl}${mediaPath}?w=320&format=jpeg 320w`,
width: 320,
sourceType: "image/jpeg",
},
{
url: `${baseUrl}${mediaPath}?w=640&format=jpeg`,
srcset: `${baseUrl}${mediaPath}?w=640&format=jpeg 640w`,
width: 640,
sourceType: "image/jpeg",
},
{
url: `${baseUrl}${mediaPath}?w=1400&format=jpeg`,
srcset: `${baseUrl}${mediaPath}?w=1400&format=jpeg 1400w`,
width: 1400,
sourceType: "image/jpeg",
},
],
};
// Use eleventy-img's generateHTML to create proper picture element
return generateHTML(metadata, {
alt,
sizes: "(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1400px",
loading: "lazy",
decoding: "async",
});
}
);
});
This so-called "vpsImageOptimization" - because I started doing it this way when I moved the blog from cloudflare - is a Transform, meaning this changes what actually gets output to the output directory. And in this case we use a regex to match img tags that have a relative url starting with media/uploads. We then build the metadata the generateHTML function expects but we are using the on-demand API endpoint. Since Eleventy-img itself is not called no images are being fetched at build time and now images are being transformed at build time either. The end result is this:
<picture><source type="image/webp" srcset="https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=320&format=webp 320w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=640&format=webp 640w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=1400&format=webp 1400w" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1400px"><source type="image/jpeg" srcset="https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=320&format=jpeg 320w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=640&format=jpeg 640w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=1400&format=jpeg 1400w" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1400px"><img alt="A black and white corgi cardigan lying in her dog bed, with her head on top of its rim. It looks like it would be uncomfortable, but she seems quite relaxed." loading="lazy" decoding="async" src="https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=320&format=jpeg" width="1400" height="undefined" srcset="https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=320&format=jpeg 320w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=640&format=jpeg 640w, https://blog.martin-haehnel.de/media/uploads/2025/9a3097241dd688eb3ee212e1e45e9bac.jpeg?w=1400&format=jpeg 1400w" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1400px"></picture>
The best part? A build of this blog now takes three minutes instead of 20 since images do not need to be fetched remotely and resized/converted on every build.[3]
That's my take on its philosophy to be precise. ↩︎
My API is served behind a reverse proxy so that the static site as well as its dynamic bits can be served from the same (sub)domain. Read more here. ↩︎
In theory this should've not happened using the build-time strategy in the first place, but I could never get it to respect the cache inside Docker/Coolify reliably. Now that isn't an issue anymore. And by design. ↩︎
-
← Previous
DailyDogo 1458 🐶