A cat sitting at a desk in front of a computer

Build-time WebP heroes with Astro and Cloudflare compile mode


Blog heroes used to live in public/blog/ as large JPEGs. Every card and post header downloaded the full file. I tried Cloudflare /cdn-cgi/image URLs in templates; they broke image paths and added runtime coupling I did not want.

Build-time optimization was the fix.

Content collection + image()

Hero paths moved next to posts:

heroImage: ./heroes/contact-form-cat.jpg

Posts load through a glob loader in src/content.config.ts; the schema uses Astro’s image() helper so paths are validated and processed:

loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
// ...
heroImage: image().optional(),

<Image /> with explicit sizes

Blog cards and post headers use astro:assets:

<Image
  src={post.data.heroImage}
  width={720}
  height={360}
  widths={[480, 720, 960]}
  format="webp"
  quality={68}
  loading="lazy"
/>

Astro + Sharp emit hashed files under /_astro/ during npm run build.

Cloudflare adapter: imageService: "compile"

In astro.config.mjs:

adapter: cloudflare({
  imageService: "compile",
}),

Images are resolved at build time. The Worker serves static bytes; it does not resize on every request.

Do you still need Polish?

For these heroes, mostly no. They are already WebP at sensible dimensions. Polish is more useful for leftover PNGs in public/ (OG images, icons) or third-party URLs you do not control.

Checklist when adding a post

  1. Add a new cat photo to src/content/blog/heroes/ (unique per post).
  2. Reference it in frontmatter as ./heroes/your-file.jpg.
  3. Run npm run blog:check-heroes to catch duplicate paths or duplicate file hashes.
  4. Run npm run build and spot-check one card and one post page.

This fits the broader pre-ship checklist and the mayfield.io rebuild notes.


Hero photo: Cat on computer, freely licensed on Wikimedia Commons.