The other day, I wanted to publish an article quickly. I generated a cover image with AI, uploaded it, and pushed everything online.
The result? A page that took 5 seconds to load on mobile. The reason? My image weighed 10 MB.
I had two choices:
- Run each image manually through a tool like Squoosh before publishing (tedious and prone to being forgotten).
- Build a system that does it for me automatically.
Like any good developer (meaning I’m a bit lazy when it comes to repetitive tasks), I chose option 2. Here is how I set up a robust image pipeline using Astro Assets and Content Collections.
The public/ Folder Problem
Initially, I stored my images in the public/ folder.
In Astro (and many other frameworks), this folder is a “pass-through zone”. Files there are served as-is, without any modification. If you put a 4000px wide PNG in there, the user will download a 4000px PNG, even on their smartphone.
This is disastrous for performance and SEO.
The Solution: src/assets/ and Collections
For Astro to optimize images, it needs to “see” and process them during the build. So, I moved my images into a protected folder: src/assets/.
But this required rethinking how my blog handles articles. I migrated to Content Collections, a powerful Astro feature for managing typed content (Markdown, MDX, JSON).
1. Defining the Schema (The Brain)
The first step was to define the structure of my articles in a configuration file (src/content/config.ts).
This is where the magic happens thanks to Zod’s image() helper. It automatically validates that the file passed in the frontmatter is indeed an existing image.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
pubDate: z.string(),
// Astro will validate and process the image here
image: image().optional(),
// Taking the opportunity to add fields for accessibility and transparency
imageAlt: z.string().optional(),
imageCaption: z.string().optional(),
aiDisclaimer: z.string().optional(),
}),
});
export const collections = {
'blog': blogCollection,
};
2. The <Image /> Component (The Muscles)
Once the configuration was done, I updated my Layout to stop using the standard HTML <img> tag, and instead use Astro’s optimized component.
This component does all the heavy lifting for me:
- It converts the image into modern formats (WebP or AVIF).
- It resizes the image.
- It generates the
widthandheightattributes to prevent layout shifts (content jumping around as the image loads).
Here is what my code looks like now:
---
import { Image } from 'astro:assets';
const { frontmatter } = Astro.props;
---
<figure>
<Image
src={frontmatter.image}
alt={frontmatter.imageAlt || frontmatter.title}
format="webp"
class="hero-image"
/>
{frontmatter.imageCaption && (
<figcaption>{frontmatter.imageCaption}</figcaption>
)}
</figure>
3. The Result (The Reward)
Today, my workflow is ideal:
- I drop a raw image (even a heavy one) into
src/assets. - I reference this image in my Markdown file:
image: "../../assets/my-image.png". - Astro does the rest.
During deployment, the 10 MB image is transformed into a perfectly sized 150 KB WebP file.
Bonus: Transparency and Accessibility
I took advantage of this overhaul to structure my metadata. I added mandatory or optional fields in my schema for:
- Alternative text (
alt): Crucial for accessibility. - The caption: To provide context.
- The AI disclaimer: A small automatic mention at the bottom of the article if the content was AI-assisted.
That’s the advantage of having a “homemade” blog: you have total control over the technical and ethical quality of what you publish.