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:

  1. Run each image manually through a tool like Squoosh before publishing (tedious and prone to being forgotten).
  2. 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 width and height attributes 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:

  1. I drop a raw image (even a heavy one) into src/assets.
  2. I reference this image in my Markdown file: image: "../../assets/my-image.png".
  3. 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.