19.3 Image Render Hook: Responsive Images from Markdown
Right, so you’re writing Markdown. You drop in an image. It’s easy. It’s simple. And it’s also tragically, comically inadequate for the modern web. You get an <img> tag. That’s it. No srcset, no sizes, no lazy loading, nothing. It’s like being handed a raw potato when you asked for french fries.
This is where the image render hook comes in. It’s our chance to intervene in Astro’s Markdown processing, grab that sad little vanilla image element, and turn it into a responsive, optimized, modern citizen of the web. Think of it as a pit stop for your images where we give them a full tune-up before they hit the track.
The Basic Anatomy of the Hook
The hook itself is a function you export from src/markdown.config.mjs (or .ts). Astro expects a specific signature: it hands you some info about the image, and you give it back an HTML string. Don’t worry, it’s less intimidating than it sounds.
Here’s the absolute bare minimum, which does exactly what Astro does by default, just to show you the structure:
// src/markdown.config.mjs
export const image = ({ src, alt, title }) => {
// title is the optional title text from markdown: 
const titleAttr = title ? `title="${title}"` : '';
return `<img src="${src}" alt="${alt}" ${titleAttr}>`;
};
Thrilling, right? We’ve successfully reinvented the wheel. Now let’s actually improve things.
Adding Responsive Images with srcset
The first order of business is ditching that single src. We need a srcset. For this, you’ll need a way to generate multiple image sizes. I strongly recommend using an image service like Cloudinary or imgix, or a built-in Astro integration like @astrojs/vercel (which gives you /_vercel/image). For this example, let’s assume we’re using a fictional service that accepts a width parameter.
// src/markdown.config.mjs
export const image = ({ src, alt, title }) => {
// 1. Define the widths we want to generate
const widths = [400, 800, 1200, 1600];
// 2. Map them to a srcset string
const srcset = widths.map(width => {
const optimizedUrl = `https://my-imageservice.com/${src}?width=${width}&format=webp`;
return `${optimizedUrl} ${width}w`;
}).join(', ');
// 3. Use the *largest* size as the default src for fallback
const defaultSrc = `https://my-imageservice.com/${src}?width=1200&format=webp`;
const titleAttr = title ? `title="${title}"` : '';
// 4. Return the fully-featured image element
return `<img
src="${defaultSrc}"
alt="${alt}"
srcset="${srcset}"
sizes="(max-width: 800px) 100vw, 800px"
loading="lazy"
decoding="async"
${titleAttr}
>`;
};
Now we’re cooking. This single change is a massive performance win. The browser gets to choose the most appropriately sized image for the user’s viewport and network conditions.
The Critical sizes Attribute (Don’t Skip This!)
See that sizes attribute? Most people leave it off and then wonder why their responsive images aren’t working. The browser uses sizes to figure out what “slot” the image is filling in the layout before it downloads it, so it can pick the right source from the srcset.
(max-width: 800px) 100vw, 800px is a safe, if slightly simplistic, default. It says “if the viewport is 800px or less, assume the image will be as wide as the viewport (100vw). Otherwise, assume it will be about 800px wide.” You should tune this to match your actual site’s CSS layout. If your article content maxes out at 70ch, your sizes should reflect that. Getting this wrong means the browser might download a massive 1600px image to display it at 400px.
Lazy Loading and Modern Attributes
You’ll notice I snuck in loading="lazy" and decoding="async". These are no-brainers.
loading="lazy": Defers loading the image until it’s near the viewport. It’s built-in, free performance. Use it on almost everything.decoding="async": Tells the browser it can decode the image asynchronously, off the main thread, which helps keep scrolling smooth.
Just be honest with yourself: if it’s your hero image above the fold, you should probably remove loading="lazy" for that one. It needs to load immediately.
Handling Absolute vs. Relative Paths
Here’s a common pitfall. Your Markdown might have relative paths (./my-cat.jpg), but your image service might require absolute paths. You need to normalize this. Astro’s src can be relative or absolute, so you need a strategy.
export const image = ({ src, alt, title }) => {
// Prepend a base path if it's a relative URL
let optimizedSrc = src;
if (src.startsWith('./') || src.startsWith('../')) {
// Assuming your images are served from /images/
optimizedSrc = `/images/${src.replace('./', '')}`;
}
// Now use optimizedSrc to build your URLs...
const srcset = [400, 800, 1200].map(w => `${optimizedSrc}?width=${w} ${w}w`).join(', ');
return `<img src="${optimizedSrc}?width=800" srcset="${srcset}" alt="${alt}">`;
};
The key takeaway? The image render hook is your single point of control. This is where you enforce consistency, ensure performance best practices, and finally give those Markdown images the respect they deserve. Don’t just throw an <img> tag over the wall and hope for the best. Take control.