Production-tested image handling for Astro sites. Covers format selection, optimization, responsive patterns, diagram rendering, and AI image generation.
1. Format Strategy
| Format | Use Case | Browser Support | Compression |
| WebP | Primary format for photos and illustrations | 97%+ | 25-35% smaller than JPEG |
| AVIF | Progressive enhancement where supported | ~85% | 50% smaller than JPEG |
| SVG | Diagrams, icons, logos | 100% | Infinite scale, tiny file size |
| PNG | Fallback for diagrams with transparency | 100% | Lossless, larger files |
element and can provide a WebP fallback. Keep SVG for all vector content.
2. Astro Component vs ![]()
Use Astro When:
- The image is imported from
src/(processed at build time) - You want automatic format conversion and optimization
- The image is a photo or illustration (raster)
---
import { Image } from 'astro:assets';
import heroImg from '../assets/hero.png';
---
<Image src={heroImg} alt="Architecture diagram" width={1200} height={630} />
Astro generates optimized WebP/AVIF with correct width, height, and srcset automatically.
Use Standard ![]()
When:
- The image is in
public/(served as-is, no processing) - The image is an SVG (no optimization needed)
- The image URL is dynamic or comes from a CMS
- The image is inside markdown content
<img
src="/images/blog/2025/diagram-1.svg"
alt="RAG pipeline architecture"
width="800"
height="450"
loading="lazy"
/>
Use for Format Negotiation:
<picture>
<source srcset="/images/hero.avif" type="image/avif" />
<source srcset="/images/hero.webp" type="image/webp" />
<img src="/images/hero.png" alt="Hero image" width="1200" height="630" loading="eager" />
</picture>
3. Lazy Loading
Rules
| Position | loading | fetchpriority | Reason |
| Hero / LCP image | eager | high | Must load immediately for LCP |
| Above fold (visible on load) | eager | auto | Visible without scrolling |
| Below fold (everything else) | lazy | auto | Defer until near viewport |
<!-- Hero image: eager load, high priority, preloaded -->
<link rel="preload" as="image" href="/images/hero.webp" type="image/webp" />
<img
src="/images/hero.webp"
alt="Hero description"
width="1200"
height="630"
loading="eager"
fetchpriority="high"
decoding="async"
/>
<!-- Below-fold image: lazy load -->
<img
src="/images/blog/diagram.svg"
alt="Architecture diagram"
width="800"
height="450"
loading="lazy"
decoding="async"
/>
Preload LCP Image in
In your Astro layout:
---
const { ogImage } = Astro.props;
---
<head>
{ogImage && (
<link rel="preload" as="image" href={ogImage} type="image/webp" />
)}
</head>
4. Aspect Ratio Preservation
Always setwidth and height attributes. This is non-negotiable. Without them, the browser cannot calculate aspect ratio before the image loads, causing CLS (Cumulative Layout Shift).
<!-- Correct: browser reserves space immediately -->
<img src="/images/photo.webp" alt="..." width="800" height="450" loading="lazy" />
<!-- Wrong: causes layout shift -->
<img src="/images/photo.webp" alt="..." loading="lazy" />
For responsive containers where the image scales:
.image-container {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
5. Responsive Images
srcset + sizes Pattern
<img
src="/images/blog/post-hero-800.webp"
srcset="
/images/blog/post-hero-400.webp 400w,
/images/blog/post-hero-800.webp 800w,
/images/blog/post-hero-1200.webp 1200w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 800px"
alt="Post hero image"
width="1200"
height="630"
loading="eager"
/>
Generating Responsive Variants
# Using sharp-cli to generate multiple sizes
npx sharp -i hero.png -o hero-400.webp --width 400 --format webp --quality 80
npx sharp -i hero.png -o hero-800.webp --width 800 --format webp --quality 80
npx sharp -i hero.png -o hero-1200.webp --width 1200 --format webp --quality 80
Or programmatically with sharp in a build script:
import sharp from 'sharp';
const sizes = [400, 800, 1200];
for (const width of sizes) {
await sharp('src/hero.png')
.resize(width)
.webp({ quality: 80 })
.toFile(public/images/hero-${width}.webp);
}
6. D2 Diagram Pipeline
The Pagezilla pipeline renders technical diagrams from D2 source code via the Kroki API. Zero local dependencies.
Pipeline: D2 Source -> Kroki API -> SVG File
D2 code (in article schema)
|
v
Kroki API (POST https://kroki.io/d2/svg)
|
v
SVG file saved to artifact directory
|
v
Embedded in HTML as <img> tag
Kroki Renderer
import base64
import zlib
import httpx
class KrokiRenderer:
def __init__(self, output_dir: str):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.base_url = "https://kroki.io"
async def render(self, diagram_code: str, filename: str,
diagram_type: str = "d2", output_format: str = "svg") -> str:
output_path = self.output_dir / f"{filename}.{output_format}"
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/{diagram_type}/{output_format}",
headers={"Content-Type": "application/json"},
json={"diagram_source": diagram_code},
timeout=30.0,
)
if response.status_code == 200:
output_path.write_bytes(response.content)
return str(output_path)
else:
logger.error(f"Kroki error ({response.status_code}): {response.text}")
return ""
Dark Theme Tokens for D2
For dark-themed sites, inject theme tokens into D2 source:
# Dark theme tokens (compatible with Kroki public instance)
direction: right
style: {
fill: "#111118"
stroke: "#00D4AA"
font-color: "#EDEDED"
}
"API Gateway" -> "Agent Router": "route request" {
style.stroke: "#00D4AA"
}
"Agent Router" -> "LLM Pool": "invoke" {
style.stroke: "#9CA3AF"
}
Kroki gotcha: The public Kroki instance does not support vars: or d2-config: blocks. These cause 400 errors. Validate D2 code before sending:
@field_validator("d2_code")
@classmethod
def no_unsupported_blocks(cls, v: str) -> str:
for blocked in ("vars:", "d2-config:"):
if blocked in v:
raise ValueError(f"D2 code contains unsupported block '{blocked}'")
return v
D2 Auto-Fix Pattern
When Kroki returns 400, ask an LLM to fix the D2 syntax before retrying:
async def render_with_fallback(self, renderer, diagram, filename):
# Attempt 1: render as-is
path = await renderer.render(diagram.d2_code, filename)
if path:
return Path(path)
# Attempt 2: LLM auto-fix
fixed_code = await llm.fix_d2_syntax(diagram.d2_code, error_hint="Kroki 400")
diagram.d2_code = fixed_code
path = await renderer.render(fixed_code, filename)
if path:
return Path(path)
# Fallback: log failure, leave placeholder
logger.error(f"Diagram {diagram.diagram_id} failed after retry")
return None
Embedding SVGs in HTML
<div class="article-diagram-style">
<div style="text-align: center;">
<img src="assets/blog_visuals/post-slug_diagram_1.svg"
alt="End-to-end RAG pipeline showing retrieval, ranking, and generation stages"
width="800" height="450"
loading="lazy" />
</div>
<p class="diagram-caption" style="text-align: center;">
Diagram 1: End-to-end RAG pipeline architecture.
</p>
</div>
SVG Width/Height Gotcha
When uploading SVGs to Ghost or other CMS platforms, the tag may not get width/height attributes automatically. Read the SVG viewBox and bake dimensions into the tag:
import re
def extract_svg_dimensions(svg_content: str) -> tuple[int, int]:
match = re.search(r'viewBox="(\d+)\s+(\d+)\s+(\d+)\s+(\d+)"', svg_content)
if match:
return int(match.group(3)), int(match.group(4))
return 800, 450 # safe default
7. AI Image Generation
Google API (Gemini Flash Image)
import httpx
import base64
async def generate_image(prompt: str, output_path: Path):
api_key = os.environ["GOOGLE_API_KEY"]
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key={api_key}"
response = await httpx.AsyncClient().post(url, json={
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageSizes": ["1024x576"], # 16:9
}
}, timeout=60.0)
data = response.json()
image_b64 = data["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]
output_path.write_bytes(base64.b64decode(image_b64))
Prompt Generation via LLM
Use a fast model to generate image prompts from article metadata:
prompt_request = f"""
Generate a banner image prompt for this article:
Title: {article.pagetitle}
Tags: {', '.join(article.tags)}
Style: modern tech vector illustration, flat design, clean UI style.
Rules: No text, no letters, no numbers, no logos. Abstract and conceptual.
Output: A single prompt string, 1-2 sentences.
"""
Image Budget
| Image Type | Target Size | Format |
| Hero / banner | <200KB | WebP |
| Blog thumbnail | <50KB | WebP |
| Case study hero | <150KB | WebP |
| Diagram | <30KB | SVG |
| Icon | <5KB | SVG |
| OG image | <100KB | PNG (for social media compatibility) |
8. Directory Structure
public/
images/
blog/
2025/ # Year-based organization
post-slug-banner.webp
post-slug-diagram-1.svg
post-slug-diagram-2.svg
2026/
...
cases/
case-slug-hero.webp
case-slug-results.webp
services/
ai-agents-icon.svg
data-engineering-icon.svg
shared/
og-default.png # Fallback OG image
logo.svg
9. Alt Text Guidelines
alt="" (empty string, not omitted)10. Optimization Tools
sharp (Node.js)
# Convert to WebP
npx sharp -i input.png -o output.webp --format webp --quality 80
# Resize and convert
npx sharp -i input.png -o output.webp --width 800 --format webp --quality 80
squoosh-cli
npx @squoosh/cli --webp '{"quality": 80}' --resize '{"width": 800}' input.png
Batch Processing Script
#!/bin/bash
# Convert all PNGs in a directory to WebP
for file in public/images/blog/**/*.png; do
output="${file%.png}.webp"
if [ ! -f "$output" ]; then
npx sharp -i "$file" -o "$output" --format webp --quality 80
echo "Converted: $file -> $output"
fi
done
Build-Time Validation
Add to your CI/CD pipeline:
# Check no image exceeds 500KB
find public/images -type f \( -name "*.webp" -o -name "*.png" -o -name "*.jpg" \) -size +500k -exec echo "OVERSIZED: {}" \;
# Check all <img> tags have width and height
grep -rn '<img' src/ | grep -v 'width=' | grep -v '.astro' && echo "WARN: img without width attribute"