Assets

Image Pipeline

Production-tested image handling for Astro sites. Covers format selection, optimization, responsive patterns, diagram rendering, and AI image generation.


1. Format Strategy

FormatUse CaseBrowser SupportCompression

WebPPrimary format for photos and illustrations97%+25-35% smaller than JPEG
AVIFProgressive enhancement where supported~85%50% smaller than JPEG
SVGDiagrams, icons, logos100%Infinite scale, tiny file size
PNGFallback for diagrams with transparency100%Lossless, larger files

Default rule: Convert all raster images to WebP. Use AVIF only when you control the 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

PositionloadingfetchpriorityReason

Hero / LCP imageeagerhighMust load immediately for LCP
Above fold (visible on load)eagerautoVisible without scrolling
Below fold (everything else)lazyautoDefer 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 set width 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 TypeTarget SizeFormat

Hero / banner<200KBWebP
Blog thumbnail<50KBWebP
Case study hero<150KBWebP
Diagram<30KBSVG
Icon<5KBSVG
OG image<100KBPNG (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

  • Describe the content, not the decoration: "Architecture diagram showing three microservices connected via Kafka" not "image" or "diagram"
  • Include the subject matter: "Python code implementing a RAG retrieval function" not "code snippet"
  • Keep it under 125 characters for screen reader compatibility
  • Decorative images get alt="" (empty string, not omitted)
  • Diagrams should describe what the diagram shows, not restate the caption
  • Charts/graphs should summarize the key insight: "Bar chart showing 3x latency improvement with connection pooling"

  • 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"