Pages CMS uploads images straight into public/images/, and Astro serves everything in public/ exactly as uploaded. So if you upload a 4000px image, you ship a 4000px image without any resizing or format conversion. The fix had to happen without touching how a post gets written, since the whole point was sticking with the CMS’s normal image-insert button.
This functionality required three pieces: a script that resizes and converts to WebP, a manifest mapping original paths to optimized ones, and a rehype plugin that rewrites <img> tags in the post bodies to point at the optimized version.
The Script
It scans public/images/, skips anything already converted (an mtime check against the source file keeps re-runs fast), resizes down to a max width, and writes a manifest:
import { readdir, mkdir, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import sharp from "sharp";
const SRC_DIR = path.join(process.cwd(), "public/images");
const OUT_DIR = path.join(SRC_DIR, "_optimized");
const MANIFEST_PATH = path.join(OUT_DIR, "manifest.json");
const MAX_WIDTH = 1600;
const SOURCE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
export async function optimizeImages() {
if (!existsSync(SRC_DIR)) return;
await mkdir(OUT_DIR, { recursive: true });
const entries = await readdir(SRC_DIR, { withFileTypes: true });
const manifest = {};
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!SOURCE_EXTENSIONS.has(ext)) continue;
const srcPath = path.join(SRC_DIR, entry.name);
const outName = `${path.basename(entry.name, ext)}.webp`;
const outPath = path.join(OUT_DIR, outName);
const srcStat = await stat(srcPath);
const needsRebuild = !existsSync(outPath) || (await stat(outPath)).mtimeMs < srcStat.mtimeMs;
if (needsRebuild) {
await sharp(srcPath)
.resize({ width: MAX_WIDTH, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(outPath);
}
const { width, height } = await sharp(outPath).metadata();
manifest[`/images/${entry.name}`] = {
src: `/images/_optimized/${outName}`,
width,
height,
};
}
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
}Wiring It In
I wanted it to work regardless of how the deploy command is configured. A prebuild script in package.json only runs if the deploy pipeline actually calls it, and on hosts where you type an arbitrary build command into a dashboard, there’s no guarantee package.json scripts get touched at all. An Astro integration sidesteps that issue entirely. It runs as part of astro build and astro dev, no matter what wraps them.
import { optimizeImages } from "../../scripts/optimize-images.mjs";
export default function optimizeImagesIntegration() {
return {
name: "optimize-images",
hooks: {
"astro:config:setup": optimizeImages,
},
};
}astro:config:setup is the earliest hook available and runs for both dev and build, early enough to finish writing the manifest before content collections render and the rehype plugin needs it.
The Rehype Plugin
It walks the rendered HTML for <img> tags whose src matches something in the manifest and rewrites them with a new src, the real width/height (so there’s no layout shift), and adds lazy loading.
import { visit } from "unist-util-visit";
import { loadImageManifest } from "./image-manifest.mjs";
export default function rehypeOptimizeImages() {
return (tree) => {
const manifest = loadImageManifest();
visit(tree, "element", (node) => {
if (node.tagName !== "img") return;
const src = node.properties?.src;
if (typeof src !== "string") return;
const optimized = manifest[src];
if (!optimized) return;
node.properties.src = optimized.src;
node.properties.width = optimized.width;
node.properties.height = optimized.height;
node.properties.loading = "lazy";
node.properties.decoding = "async";
});
};
}The manifest gets read fresh inside the returned transform function, so each file gets whatever the manifest looks like at that point in the pipeline, not a snapshot taken once when the plugin was built.
Once Versus Per File
rehypeOptimizeImages() runs once, when Astro sets up the markdown pipeline. The function it returns is what gets called separately for every markdown file. That distinction is why loadImageManifest() is called inside the returned function and not the outer one. Reading it in the outer function would mean reading it once, at plugin-construction time, possibly before the optimize-images integration had finished writing manifest.json to disk. Reading it per file instead means the read happens later in the pipeline, after that write has had time to complete.
Wiring It Up
Both pieces get registered in astro.config.mjs The integration runs the resize script, and the rehype plugin rewrites the markdown output:
import rehypeOptimizeImages from "./src/lib/rehype-optimize-images.mjs";
import optimizeImagesIntegration from "./src/integrations/optimize-images.mjs";
export default defineConfig({
integrations: [/* ...existing integrations, */ optimizeImagesIntegration()],
markdown: {
rehypePlugins: [rehypeOptimizeImages],
},
});A Beneficial Side-Effect
The manifest turned out to be useful somewhere else. The site lets a post set a custom social-preview image, and the og:image:width/ height meta tags need to match the image’s dimensions instead of a hardcoded default. Since the manifest already has the width and height for every optimized image, reading it there replaced what would’ve otherwise been a second resize pipeline.
End-to-End
Now, I don’t need to jump into an image editor like Photoshop to resize and optimize before I publish anything.
An image uploaded through Pages CMS gets resized, converted to WebP, and served with correct dimensions and lazy loading, and nothing about writing the post changed at all.
Lessons Learned
- A factory function runs once, so it matters what “once” means for your data. If a plugin reads shared state at construction time instead of inside the function that runs per file, every file ends up working from one stale snapshot. Read fresh data wherever “fresh” actually needs to happen.
- A build artifact made for one reason can quietly solve another. A manifest built to rewrite
<img>tags can already have everything needed to fix an unrelated problem, like incorrect social-preview image dimensions, without any extra pipeline.