Images — Responsive, Modern, and Good Enough for Now
CSS Grid breakout layout for Medium-style image sizing, a Figure component for captions and lazy loading, and the decision to defer optimization until the content demands it.
The SEO and syndication post covered the invisible features: SEO metadata, RSS, the sitemap, and robots.txt. That work serves machines. Images are the opposite. They’re the most immediately visible element on a page, and the easiest to get wrong.
But this is a text-first blog. The content is the point, not the platform. Most posts are long-form writing with code samples, and images are occasional illustrations. That shapes the decision: get the layout right, keep optimization simple, and revisit when the content demands it.
Astro 6 was in beta when this was written, so what follows is a point-in-time snapshot. The options may shift as the framework stabilizes.
What Astro 6 offers
Astro has built-in image optimization through the astro:assets module. The core tools:
<Image>accepts a local import or remote URL and outputs an optimized<img>withwidth,height,loading="lazy", anddecoding="async"attributes. Converts to WebP by default. Prevents cumulative layout shift by requiring explicit dimensions.<Picture>does the same optimization but outputs a<picture>element with multiple<source>elements for format negotiation. Lets the browser choose WebP, AVIF, or the original format based on support.
Both components process local images at build time using Sharp. The processing is zero-runtime; no image service needed for a static site. In practice:
---
import { Image } from 'astro:assets';
import hero from '../assets/hero-placeholder.png';
---
<Image src={hero} alt="A descriptive caption for the image" />
The output is a fully attributed <img> tag with optimized format, explicit dimensions, lazy loading, and async decoding, all generated at build time from a single import.
Astro 6 also introduced a responsiveImages configuration that can generate srcset and sizes attributes automatically, producing multiple resolutions from a single source. In the beta, this was available as an experimental flag. The concept is sound (responsive variants at build time without manual breakpoint management), but I wasn’t confident enough in the API stability to build on it yet.
A second constraint is specific to this project. Astro’s image processing relies on Sharp, which runs in Node.js. The Cloudflare adapter runs the dev server in workerd (Cloudflare’s JavaScript runtime), and Sharp doesn’t work there. In Astro 6 beta, using the <Image> component with the Cloudflare adapter throws a CJS compatibility error. The default dev image endpoint imports picomatch, which uses require(), and workerd doesn’t support CommonJS.
This is a known issue. There’s an open PR to fix it by updating the Cloudflare adapter’s default image service, but it hasn’t merged yet. The built-in image optimization tools exist, they just don’t work with this project’s deployment target right now.
The other constraint is content-level. These components only work in .astro files. In MDX content, a standard Markdown image renders as a plain <img> tag:

That produces:
<img src="https://picsum.photos/1200/675" alt="A placeholder image" />
No optimization, no lazy loading, no format conversion. Between the Cloudflare adapter incompatibility and the MDX rendering gap, Astro’s built-in image tools aren’t directly usable for this site’s primary use case: images inside blog posts.
The options I evaluated
Beyond Astro’s built-in tools, I looked at three categories:
Framework-agnostic libraries. Unpic generates responsive <img> tags with srcset and sizes attributes and has an Astro integration. The appeal is portability: if I ever moved away from Astro, the image handling would transfer. The cost is another dependency and another abstraction layer between content and output.
Cloud image services. Cloudinary and Cloudflare Images both offer on-the-fly transformation via URL parameters: resize, crop, convert formats, adjust quality. Cloudinary has a generous free tier. Cloudflare Images is a natural fit since the site already runs on Cloudflare Workers. Both shift processing from build time to request time, which handles dynamic content well but adds cost and complexity for a static blog with a handful of images.
The minimal path. Standard Markdown images, styled with CSS, served as-is. No optimization pipeline, no additional dependencies. The images display correctly and respond to viewport changes. They just aren’t optimized for file size or modern formats.
The decision
I chose the minimal path for optimization, but spent the time on layout instead.
This blog currently has very few images. The posts are primarily text and code. Adding a build-time optimization pipeline, a cloud service, or an image CDN for a small number of images introduces complexity that doesn’t pay for itself yet. And the most natural option, Astro’s built-in tools, doesn’t work with the Cloudflare adapter in beta. I could work around that, but working around a beta incompatibility to optimize images that barely exist felt like the wrong use of time.
The layout was the part worth doing now. Medium-style image sizing (standard, wide, full-bleed) is a layout concern, not an optimization concern. It’s pure CSS, zero dependencies, and it makes images a real layout element in the prose column. It also keeps accessibility straightforward: semantic HTML, required alt text enforced at the component level, and decorative images handled correctly from the start. When optimization becomes relevant, it layers on top without any structural changes.
The layout: CSS Grid breakouts
The previous prose layout was a simple max-width: 65ch centered box. That works for text, but it caps everything at the content column width. No room for a wider image to breathe.
There are several ways to handle breakout layouts. I landed on a CSS Grid with named column lines, a pattern documented by Ryan Mulligan. It’s not the only approach, but it’s well-tested and did what I needed without requiring me to overthink it. The grid defines three named regions:
.prose {
--prose-gap: clamp(1rem, 6vw, 3rem);
--prose-content: min(65ch, 100% - var(--prose-gap) * 2);
--prose-wide: minmax(0, calc((1000px - 65ch) / 2));
--prose-full: minmax(var(--prose-gap), 1fr);
display: grid;
grid-template-columns:
[full-start] var(--prose-full)
[wide-start] var(--prose-wide)
[content-start] var(--prose-content) [content-end]
var(--prose-wide) [wide-end]
var(--prose-full) [full-end];
}
.prose > * { grid-column: content; }
Content (center, 65ch) is the default column. All text, headings, code blocks, lists, and standard images land here. Same measure as before. Nothing changes for existing content.
Wide (~1000px) extends into the margins on both sides. Good for large diagrams, screenshots, or images that benefit from more horizontal space without going edge-to-edge.
Full (edge-to-edge) bleeds to the viewport edges, minus a minimum gutter. The minmax(var(--prose-gap), 1fr) ensures there’s always at least clamp(1rem, 6vw, 3rem) of breathing room. The image never truly touches the browser chrome.
The breakout is controlled by CSS classes on direct children of .prose:
.prose > .wide { grid-column: wide; }
.prose > .full { grid-column: full; }
.prose > .small { grid-column: content; max-width: 45ch; margin-inline: auto; }
There’s also a small variant, still within the content column but constrained to 45ch and centered. Useful for screenshots of narrow UI elements or small illustrations that would look stretched at full content width.
What this required upstream
The grid approach needs room to expand. The previous layout chain (max-w-3xl on the page wrapper, then max-w-[65ch] on the article) capped everything at ~768px. There was physically no space for “wide” to go.
The fix: post pages now opt into flush={true} on the base layout, which removes the outer max-w-3xl container. The back link, header, and pagination self-center at 65ch with matching gutters (px-[clamp(1rem,6vw,3rem)]), aligning with the prose grid’s content column. The grid handles the prose content width; the surrounding elements handle their own.
Responsive behavior
The minmax(0, ...) on the wide track means it collapses at narrow viewports without any breakpoints:
| Viewport | Content | Wide | Full |
|---|---|---|---|
| 1440px | 65ch (~730px) | ~1000px | ~1344px |
| 768px | ~676px | collapses toward content | near-edge |
| 375px | ~330px | collapses to content | near-edge |
At mobile widths, “wide” and “content” converge. The wide track shrinks to zero. “Full” still extends to the edges minus the minimum gutter. The layout degrades naturally without any responsive logic.
Margin adjustment for grid
One subtlety: CSS Grid doesn’t collapse margins between grid items. In the previous layout, a paragraph’s margin-bottom: 1.5em and a heading’s margin-top: 2.5em would collapse to 2.5em. In the grid, they stack to 4em.
The fix is to reduce margin-top values: headings from 2.5em to 1.5em, images from 2em to 1em, horizontal rules from 3em to 1.75em. The preceding element’s bottom margin still contributes, so the total spacing approximates the original collapsed values.
The Figure component
Standard Markdown images () continue to work for inline images at content width. But Markdown syntax has gaps: no loading="lazy", no <figcaption>, no size control.
The Figure component covers what Markdown can’t:
---
interface Props {
src: string;
alt: string;
caption?: string;
credit?: string;
size?: 'standard' | 'wide' | 'full' | 'small';
}
const { src, alt, caption, credit, size = 'standard' } = Astro.props;
---
<figure class:list={[size !== 'standard' && size]}>
<img src={src} alt={alt} loading="lazy" decoding="async" />
{(caption || credit) && (
<figcaption>
{caption && <Fragment set:html={caption} />}
{credit && <span class="figure-credit"><Fragment set:html={credit} /></span>}
</figcaption>
)}
</figure>
Captions and credits are string props rendered with set:html, which allows inline HTML like links. I considered a slot-based approach with named caption and credit slots, which would give native XSS protection and full MDX composability inside captions. But for a blog where I control all the content, the props approach is simpler: one line per image, no child elements to wrangle. If credits become frequent or captions need rich MDX content beyond simple links, named slots are a clean upgrade path.
Usage in MDX:
import Figure from '../../components/Figure.astro';
{/* Standard Markdown still works */}

{/* Standard with caption and lazy loading */}
<Figure src="image-url.jpg" alt="Description" caption="Optional caption." />
{/* Wide with credit */}
<Figure src="image-url.jpg" alt="Description" size="wide"
caption="Extends into margins."
credit='Photo: <a href="https://unsplash.com">Unsplash</a>' />
{/* Full bleed */}
<Figure src="image-url.jpg" alt="Description" size="full" />
{/* Small */}
<Figure src="image-url.jpg" alt="Description" size="small" caption="A smaller image." />
The component outputs a <figure> element as a direct child of .prose, so the breakout classes (wide, full, small) work through the grid. loading="lazy" and decoding="async" are applied automatically, which solves the lazy loading gap without an optimization pipeline. Captions render as <figcaption> in the UI font, muted and centered.
Live examples
Each size variant in practice:
Featured images
The content schema already has an optional image field:
image: z.string().optional(),
When a post includes an image in its frontmatter, the post page now renders it as a featured image between the header and the prose content. It displays at max-width: 1000px (matching the wide column) with loading="eager" since it’s above the fold. The alt text is intentionally empty (alt="") because the post title immediately above provides the context; the image is decorative in this position.
This is the same field that feeds into the SEO component for Open Graph and Twitter Card meta tags. One field, two uses: social previews and on-page display.
Social previews
The frontmatter image field feeds into the SEO component through a fallback chain:
const resolvedImage = image ?? post?.image ?? '/og-default.png';
const imageURL = new URL(resolvedImage, Astro.site);
An explicit image prop takes priority, then the post’s frontmatter image, then a site-wide default. This is documented in the SEO post.
Alt text as a requirement
CONVENTIONS.md includes a clear rule: alt text is required on all <img> elements. This isn’t a style preference. It’s an accessibility requirement under WCAG 2.2. Images without alt text are invisible to screen readers. Decorative images without an empty alt="" create noise in the accessibility tree.
Markdown image syntax makes the mechanics easy. The alt text is the first thing written: . The Figure component requires alt as a prop, and TypeScript enforces its presence at build time. The challenge isn’t syntax, it’s discipline. “Screenshot” or “Image” aren’t meaningful descriptions. Alt text should convey what the image communicates in context. What would a reader need to know if the image didn’t load?
For purely decorative images (a background texture, an ornamental divider), an empty alt attribute (alt="") tells assistive technology to skip it entirely. That’s the correct behavior, and it requires an intentional choice for each image rather than a blanket rule.
What I’m deferring
- Build-time image optimization. Astro’s
<Image>and<Picture>components are the natural first step. The trigger is two things happening together: the Cloudflare adapter fix merging and the blog accumulating enough images that file size starts to matter. Neither is true yet.1 - Cloud image services. Cloudinary or Cloudflare Images for on-the-fly transformation. This becomes relevant if the blog shifts toward image-heavy content or if responsive art direction (different crops at different viewports) becomes a real need. For a handful of illustrations in text-heavy posts, it’s overhead.
- Responsive image markup.
srcsetandsizesattributes for resolution switching across viewports. The experimentalresponsiveImagesconfiguration in Astro 6 is the natural path once the API stabilizes.1 - Image galleries. MDX components for image grids or lightboxes. These are content patterns that don’t exist in the blog yet. The trigger is a post that actually needs a gallery. Building the component before that post exists would be speculative.
The layout foundation is in place. When optimization becomes relevant, it layers on top without changing the layout or content structure.
Footnotes
-
The Cloudflare adapter incompatibility described in this post was resolved before Astro 6 left beta. The
<Image>and<Picture>components now work with the Cloudflare adapter in the GA release, andresponsiveImagesis a stable configuration option. The decisions documented here were made during the beta; the constraints that informed them have since shifted. ↩ ↩2