Skip to main content

A Docusaurus Plugin to Optimize Images at Build Time

· 5 min read ·
Docux
Curious explorer, a bit of a mad experimenter, and a bit of a contributor.

Developer Development License: MIT AI 90%

My blog was quietly shipping 13.7 MB of images. A few PNGs weighed over a megabyte, and several WebP files were surprisingly heavy — over-sized dimensions and default encoder quality. Heavy images hurt load time, Core Web Vitals, and ultimately SEO. So I built a small local Docusaurus plugin that optimizes every image at build time, and it cut the deployed images almost in half (-49%) — without touching a single source file.

This article walks through the plugin: how it hooks into the build, how a content-hash cache makes it incremental (already-optimized images become a reusable artifact), and the real numbers it produced.

Design goals

Non-destructive (never modify sources), incremental (process new images only), and reference-safe (formats are preserved, so no URL ever breaks).

Why a build-time plugin (and not the source images)?

There are several ways to optimize images. I picked the one that fits a Docusaurus site best:

  • At build time, on the build/ output. Docusaurus copies static/ and processes co-located images into build/. Optimizing there means every image is covered — including the hashed assets from MDX — while my source files stay pristine.
  • Non-destructive. Re-compressing sources in place is risky: quality degrades a little on every pass, and a mistake is permanent. Working on the generated output keeps the originals safe.
  • Reference-safe. Each image keeps its format and filename (WebP → WebP, PNG → PNG), so no link, image: front matter, or <img> src ever changes.

The one downside — the build output is regenerated every time, so a naive version would re-encode everything on every build. That's what the cache solves.

The tool: sharp

sharp is the de-facto library for fast image processing (resize, re-encode, format conversion). It ships prebuilt binaries, so it just works locally and in CI.

> user@machine: ~/yourproject

npm install sharp

The plugin

The plugin lives in plugins/, like my other local plugins.

📁plugins
📁docusaurus-plugin-image-optimizer
index.cjs
readme.md

It hooks into Docusaurus's postBuild lifecycle, which runs only during docusaurus build (never during docusaurus start). Here is the whole thing:

/**
* Docusaurus Plugin: image-optimizer
*
* Compresses and resizes the raster images in the production build output
* (`build/`) so the deployed site ships lighter images — without ever touching
* your source files.
*
* How it stays fast and incremental:
* - It runs in the `postBuild` lifecycle, i.e. only during `docusaurus build`
* (never during `docusaurus start`).
* - Every image is keyed by a hash of its bytes + the optimization settings.
* The chosen (optimized) result is stored in a persistent cache
* (`node_modules/.cache/docusaurus-plugin-image-optimizer/`).
* - On the next build, an image whose hash is already in the cache is restored
* from it instead of being re-encoded. So the first build processes every
* image; later builds only process the new or changed ones. In CI, caching
* that folder (see the GitHub Actions workflow) turns the already-optimized
* images into a build artifact reused across runs.
*
* Transform: each image is resized down to `maxWidth` (never enlarged) and
* re-encoded at `quality`, keeping its original format (WebP → WebP, PNG → PNG,
* JPEG → JPEG) so every existing reference keeps working. If optimizing doesn't
* actually shrink the file, the original is kept.
*
* Options (all optional):
* - quality {number} Encoder quality, 1–100. Default 80.
* - maxWidth {number} Max width in px; wider images are downscaled. Default 1920.
* - extensions {string[]} File extensions to process. Default [".png",".jpg",".jpeg",".webp"].
* - cacheDir {string} Cache location. Default node_modules/.cache/docusaurus-plugin-image-optimizer.
* - concurrency {number} Parallel workers. Default 8.
*
* See readme.md for more details.
*/

const fsp = require("fs/promises");
const path = require("path");
const crypto = require("crypto");

const DEFAULT_OPTIONS = {
quality: 80,
maxWidth: 1920,
extensions: [".png", ".jpg", ".jpeg", ".webp"],
cacheDir: null,
concurrency: 8,
};

// Bump when the optimization logic changes, to invalidate stale cache entries.
const CACHE_VERSION = "v1";

function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

/** Recursively collect files whose extension is in `extensions`. */
async function collectImages(dir, extensions) {
const found = [];
async function walk(current) {
const entries = await fsp.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (extensions.includes(path.extname(entry.name).toLowerCase())) {
found.push(full);
}
}
}
await walk(dir);
return found;
}

/** Resize + re-encode a single image buffer, keeping its format. */
async function optimizeBuffer(sharp, buffer, ext, { quality, maxWidth }) {
const animated = ext === ".webp" || ext === ".gif";
let pipeline = sharp(buffer, { animated });
const meta = await pipeline.metadata();

// Only downscale still images; leave animated frames untouched to stay safe.
if (maxWidth && meta.width && meta.width > maxWidth && !meta.pages) {
pipeline = pipeline.resize({ width: maxWidth, withoutEnlargement: true });
}

switch (ext) {
case ".webp":
pipeline = pipeline.webp({ quality });
break;
case ".png":
pipeline = pipeline.png({ compressionLevel: 9, palette: true, quality });
break;
case ".jpg":
case ".jpeg":
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
break;
default:
return null;
}
return pipeline.toBuffer();
}

/** Run `worker` over `items` with at most `limit` in flight at once. */
async function mapLimit(items, limit, worker) {
let cursor = 0;
const runners = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const current = cursor++;
await worker(items[current]);
}
}
);
await Promise.all(runners);
}

module.exports = function pluginImageOptimizer(context, options = {}) {
const opts = { ...DEFAULT_OPTIONS, ...options };
const cacheDir =
opts.cacheDir ||
path.join(
context.siteDir,
"node_modules/.cache/docusaurus-plugin-image-optimizer"
);
const paramsSignature = `q${opts.quality}-w${opts.maxWidth}-${CACHE_VERSION}`;

return {
name: "docusaurus-plugin-image-optimizer",

async postBuild({ outDir }) {
let sharp;
try {
sharp = require("sharp");
} catch {
console.warn(
"[image-optimizer] `sharp` is not installed — skipping image optimization."
);
return;
}

await fsp.mkdir(cacheDir, { recursive: true });
const images = await collectImages(outDir, opts.extensions);

const stats = {
optimized: 0,
fromCache: 0,
noGain: 0,
failed: 0,
before: 0,
after: 0,
};

await mapLimit(images, opts.concurrency, async (file) => {
try {
const original = await fsp.readFile(file);
stats.before += original.length;

const hash = crypto
.createHash("sha256")
.update(original)
.digest("hex");
const ext = path.extname(file).toLowerCase();
const cacheFile = path.join(
cacheDir,
`${hash}-${paramsSignature}${ext}`
);

// The cache stores the "best" bytes we decided to keep for this
// source image (either the optimized version or the original).
let best;
try {
best = await fsp.readFile(cacheFile);
stats.fromCache++;
} catch {
const optimized = await optimizeBuffer(sharp, original, ext, opts);
best =
optimized && optimized.length < original.length
? optimized
: original;
await fsp.writeFile(cacheFile, best);
if (best.length < original.length) stats.optimized++;
else stats.noGain++;
}

if (best.length < original.length) {
await fsp.writeFile(file, best);
stats.after += best.length;
} else {
stats.after += original.length;
}
} catch (err) {
stats.failed++;
console.warn(
`[image-optimizer] Skipped ${path.basename(file)}: ${err.message}`
);
}
});

const saved = stats.before - stats.after;
const percent = stats.before
? ((saved / stats.before) * 100).toFixed(1)
: "0.0";

console.log("\n=== Image optimizer ===");
console.log(
`Images: ${images.length} (optimized: ${stats.optimized}, from cache: ${stats.fromCache}, no gain: ${stats.noGain}, failed: ${stats.failed})`
);
console.log(
`Total: ${formatBytes(stats.before)} → ${formatBytes(stats.after)} (saved ${formatBytes(saved)}, -${percent}%)`
);
console.log("=======================\n");
},
};
};

The transform

For each image, sharp:

  1. Downscales it to maxWidth (default 1920px), never enlarging.
  2. Re-encodes it at quality (default 80), keeping the original format.
  3. Keeps the original if the "optimized" version isn't actually smaller — so we never inflate a file.

The cache = the incremental part

This is the key idea. Each image is keyed by sha256(bytes) + settings, and the chosen result is stored in a persistent folder:

node_modules/.cache/docusaurus-plugin-image-optimizer/

On the next build, an image whose hash is already in the cache is restored from it instead of being re-encoded. So:

  • First build: every image is processed.
  • Later builds: only new or changed images are processed; everything else is a cache hit.

A nice side effect: two byte-identical images (say, the same picture referenced from static/ and from a blog post) share a hash, so the work is done once and deduplicated automatically.

The cache is the "artifact"

Because the cache lives under node_modules/.cache, it's git-ignored. In CI we persist it with actions/cache (below), which is exactly what makes the already-optimized images a build artifact reused across runs.

Registering the plugin

In docusaurus.config.js:

import pluginImageOptimizer from "./plugins/docusaurus-plugin-image-optimizer/index.cjs";

const config = {
plugins: [
[pluginImageOptimizer, { quality: 80, maxWidth: 1920 }],
// ...other plugins
],
};

The results

After one npm run build, the plugin prints a report:

=== Image optimizer ===
Images: 69 (optimized: 46, from cache: 22, no gain: 1, failed: 0)
Total: 13.34 MB → 6.76 MB (saved 6.58 MB, -49.3%)
=======================

A few representative files:

ImageBeforeAfterSaved
404bg.webp855 KB @ 2473px73 KB @ 1920px-91%
api.png1729 KB414 KB-76%
icon/css.webp904 KB513 KB-43%

The biggest wins come from resizing over-sized images (that 2473px background didn't need to be wider than the layout) combined with a sane re-encode. And a second build is basically instant for images — everything comes from the cache:

Images: 69 (optimized: 0, from cache: 69, no gain: 0, failed: 0)

Caching in CI

My GitHub Actions workflow builds on every push. To reuse the optimized images across runs — so CI only re-encodes what actually changed — I cache the plugin folder before the build step:

- name: Cache optimized images
uses: actions/cache@v4
with:
path: node_modules/.cache/docusaurus-plugin-image-optimizer
key: image-optimizer-${{ github.run_id }}
restore-keys: |
image-optimizer-

The restore-keys prefix restores the most recent cache, and a run-specific key saves an updated one — so the cache keeps growing with each new image.

Tuning and trade-offs

  • quality / maxWidth are plugin options. Lower quality or a smaller max width means smaller files; tune to taste.
  • PNG uses a palette (lossy) re-encode for much smaller files. It's great for screenshots and illustrations; if you have PNGs where exact pixels matter, raise the quality or exclude them via extensions.
  • Animated images are left un-resized to stay safe.
  • The plugin only runs in build, so your dev server stays fast.
Takeaway

A ~130-line postBuild plugin, sharp, and a content-hash cache were enough to halve my deployed image weight — safely, incrementally, and automatically on every deploy.


This article is part of the Docusaurus Plugins series:

No related posts.

Back to top