Home → Build a placeholder image service (placehold.it clone) with Node.js, Express 5 and sharp

Build a placeholder image service (placehold.it clone) with Node.js, Express 5 and sharp

By · Node.js & JavaScript developer
Published July 29, 2021 · Updated October 1, 2026

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>`) });
  1. 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 with PathError [TypeError]: Missing parameter name at index 2: /*. Wildcards must be named (/*splat), and req.params.splat is then an array. The rest of the testing used that fix.
  2. The text is interpreted as SVG markup. ?text=Tom & Jerry made the SVG invalid, and the response was the error Input 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.
  3. There's no size limit. /16000/16000 took 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/20000 only failed because sharp's default limit of 268 megapixels stopped it.)
  4. The text rendered as empty boxes. Slim Docker images include no fonts at all, so every character became a "tofu" box.

Placeholder from the original code in node:24-slim: the injected text is red and every character is an empty box because no fonts are installed

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 sharp
import 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:

Placeholder rendered in plain node:24-slim: the text is a row of empty boxes

The same placeholder with fonts-dejavu-core installed: the text reads Code with Node.js

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.

Default placeholder: a light grey 600 by 400 image with 600×400 written in the middle

Placeholder whose text is the markup <tspan fill=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

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.