Build a placeholder image service (placehold.it clone) with Node.js, Express 5 and sharp
placehold.it, the service that turned a URL like /600x400 into a grey image with its size written on it, shut down years ago. Clones like placehold.co took its place, but the idea is simple enough to run yourself: draw the image as an SVG and let sharp convert it to PNG, WebP or AVIF.
This post builds one with Express 5.2.1 and sharp 0.35.5, running in node:24-slim (Node 24.21). The original version of this post had a working core, but testing it turned up four problems that are easy to carry into any image endpoint. The first section covers them, and the rest builds a version without them.
What went wrong in the original
The original code took everything from the path (/400/200/bg/ff9900/text/Hello) and pasted the values into an SVG string:
app.get('/*', async function (req, res) {
const params = req.params[0].split('/');
// …
composite.push({ input: Buffer.from(`<svg …><text …>${value}</text></svg>`) });- It doesn't start on Express 5. Express 5 uses path-to-regexp v8, where a bare
*isn't allowed. The server crashed at startup withPathError [TypeError]: Missing parameter name at index 2: /*. Wildcards must be named (/*splat), andreq.params.splatis then an array. The rest of the testing used that fix. - The text is interpreted as SVG markup.
?text=Tom & Jerrymade the SVG invalid, and the response was the errorInput buffer has corrupt header: glib: XML parse error, sent with status 200. Text containing<tspan fill="red">pwned</tspan>was rendered as markup, in red. Anyone could change what your images show. - There's no size limit.
/16000/16000took 4.3 seconds, used about 330 MiB of memory and returned a 5 MB PNG. A handful of such requests is enough to tie up a small server. (/20000/20000only failed because sharp's default limit of 268 megapixels stopped it.) - The text rendered as empty boxes. Slim Docker images include no fonts at all, so every character became a "tofu" box.

That image shows both problems at once: the red comes from the injected <tspan>, and the boxes are characters with no font to draw them.
The server
npm install express sharpimport express from 'express';
import sharp from 'sharp';
const MAX_SIDE = 4000;
const TYPES = { png: 'image/png', webp: 'image/webp', avif: 'image/avif', jpg: 'image/jpeg', svg: 'image/svg+xml' };
// Only accept 3- or 6-digit hex colours, so nothing else can reach the SVG
const hex = (value) => (/^(?:[0-9a-f]{3}){1,2}$/i.test(value ?? '') ? `#${value}` : undefined);
const escapeXml = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function placeholderSvg(width, height, { bg = '#cbd5e1', fg = '#334155', text = `${width}×${height}` }) {
// Roughly fit the text: sans-serif glyphs average about 0.6em wide
const fontSize = Math.max(8, Math.floor(Math.min(height * 0.3, (width * 0.9) / (0.6 * text.length))));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<rect width="100%" height="100%" fill="${bg}"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central"
font-family="DejaVu Sans, sans-serif" font-size="${fontSize}" fill="${fg}">${escapeXml(text)}</text>
</svg>`;
}
const app = express();
// /600x400 /600x400.webp /600x400.png?bg=0891b2&fg=fff&text=Hello
app.get('/:size', async (req, res) => {
const match = /^(\d{1,4})x(\d{1,4})(?:\.(png|webp|avif|jpg|svg))?$/.exec(req.params.size);
if (!match) return res.status(400).type('text').send('Use /WIDTHxHEIGHT, for example /600x400.png\n');
const [, width, height, format = 'png'] = match.map((v, i) => (i === 1 || i === 2 ? Number(v) : v));
if (!width || !height || width > MAX_SIDE || height > MAX_SIDE) {
return res.status(400).type('text').send(`Width and height must be between 1 and ${MAX_SIDE}\n`);
}
const { bg, fg, text } = req.query;
const svg = placeholderSvg(width, height, {
bg: hex(bg),
fg: hex(fg),
text: typeof text === 'string' && text ? text.slice(0, 100) : undefined,
});
// The URL fully determines the image, so it can be cached forever
res.set('Cache-Control', 'public, max-age=31536000, immutable');
if (format === 'svg') return res.type(TYPES.svg).send(svg);
const image = sharp(Buffer.from(svg));
const buffer = await (format === 'jpg' ? image.jpeg() : image[format]()).toBuffer();
res.type(TYPES[format]).send(buffer);
});
app.listen(3130, () => console.log('Placeholder server on http://localhost:3130'));The URL looks like placehold.co's: the size and format in the path, the options in the query string. How the four problems are handled:
- Express 5 routing: a single named parameter,
/:size, parsed with one regular expression that accepts only up to four digits per side and only the listed formats. - Escaping: colours must be 3- or 6-digit hex, or they're ignored. The text goes through
escapeXml(), so the five XML special characters become character references.typeof text === 'string'also rejects?text=a&text=b, which Express parses as an array. - Limits: at most 4000 px per side (16 megapixels) and 100 characters of text.
- Caching: the URL fully determines the image, so the response can be cached by browsers and CDNs for a year.
The SVG is the whole image: a rectangle and a centred <text>. dominant-baseline="central" centres the text vertically, and the font size is a rough fit based on the average width of a sans-serif glyph. sharp renders SVG at its declared width and height, so the output has exactly the requested size. For .svg, the server skips sharp and returns the markup directly, which is the smallest and sharpest option for a placeholder.
Express 5 also handles errors in async route handlers: if toBuffer() rejects, the error goes to Express's error handler and the client gets a 500, instead of a request that never finishes.
Fonts in Docker
sharp renders SVG text with whatever fonts the system has, and node:24-slim has none. The same server, run in the plain slim image and in an image with one font package installed:


FROM node:24-slim
# Slim images ship without fonts: without this, all text renders as empty boxes
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.mjs .
USER node
EXPOSE 3130
CMD ["node", "server.mjs"]fonts-dejavu-core (about 3 MB installed) covers Latin, Greek and Cyrillic scripts. For Chinese, Japanese or Korean text, install a font that covers them as well. The font-family in the SVG names DejaVu Sans first, so the output looks the same on every machine that has it.
Testing it
Requests from another container on the same Docker network, decoding each image with sharp to check its real format and size:
/600x400 200 image/png 16882B 91ms png 600×400
/600x400.webp 200 image/webp 4780B 23ms webp 600×400
/600x400.avif 200 image/avif 2858B 217ms heif 600×400
/600x400.jpg 200 image/jpeg 10966B 16ms jpeg 600×400
/600x400.svg 200 image/svg+xml; charset=utf-8 290B 4ms svg 290B
/400x200.png?text=Tom%20%26%20Jerry 200 image/png 6714B 11ms png 400×200
/400x200.png?bg=red%22%2F%3E%3Cscript%3E 200 image/png 7360B 12ms png 400×200
/5000x5000 400 text/plain; charset=utf-8 44B 2ms Width and height must be between 1 and 4000
/0x100 400 text/plain; charset=utf-8 44B 2ms Width and height must be between 1 and 4000
/abc 400 text/plain; charset=utf-8 44B 2ms Use /WIDTHxHEIGHT, for example /600x400.png
/600x400.gif 400 text/plain; charset=utf-8 44B 2ms Use /WIDTHxHEIGHT, for example /600x400.png
cache-control: public, max-age=31536000, immutable
200 × 600x400 png: 11.1 ms each
4000x4000 png: 157 ms, 418046 B(sharp reports AVIF as heif, the container format AVIF is built on.) The attempt to break out of the fill attribute through bg was simply ignored, because it isn't a hex colour, so the default background was used. The first PNG and the AVIF were slower because they include warm-up. After that, a typical placeholder took about 11 ms, and the largest allowed image took 157 ms instead of 4.3 seconds. The container used 65 MiB afterwards.

pwned, shown literally in white instead of being interpreted" width="400" height="200" loading="lazy">
The injection attempt from earlier now shows up as literal text. With the immutable Cache-Control header, a CDN in front of the server answers repeat requests itself, so each image is generated only once.
Sources & further reading
- Express 5 migration guide: path route matching syntax and rejected promises in handlers
- sharp: constructor (SVG input,
limitInputPixels) and output formats - MDN: dominant-baseline and text-anchor
- OWASP: cross-site scripting (why user input must be escaped before it goes into markup)
- Debian package: fonts-dejavu-core