[Astro] Image Optimization — Custom rehype Plugin

2026-03-29 hit count image

Sharing how to implement a custom rehype plugin that automatically converts markdown images to AVIF/WebP in Astro. Covers image conversion with Sharp, caching, lazy loading, and more.

astro

Overview

In the previous post SEO Implementation, we covered search engine optimization. In this post, I’ll explain in detail the custom rehype plugin (rehype-picture.mjs) developed for image optimization, which is key to blog performance.

This plugin automatically converts <img> elements in markdown into <picture> elements to serve images in AVIF/WebP formats. For a broader perspective on image optimization, refer to the Lighthouse Performance Optimization Guide post.

Why a Custom Plugin

Astro has a built-in <Image> component, but it cannot be used in markdown content. There are two ways to insert images in markdown:

<!-- Method 1: Markdown syntax -->

![Alt text](/assets/images/example.jpg)

<!-- Method 2: HTML img tag -->
<img src="/assets/images/example.jpg" alt="Alt text" />

In both cases, they render as standard <img> tags, so they can’t benefit from Astro’s <Image> component optimization. Therefore, I developed a rehype plugin that automatically optimizes images in the markdown processing pipeline.

How the Plugin Works

The overall flow of rehype-picture.mjs is as follows:

Markdown rendering

rehype AST (HAST) generation

rehype-picture plugin execution

1. Search for <img> elements (HAST element + raw HTML)
2. Generate AVIF/WebP converted files from original images (Sharp)
3. Replace <img> with <picture>

Final HTML output

Configuration in astro.config.mjs

To use the custom rehype-picture plugin, add it to the markdown.rehypePlugins option in astro.config.mjs.

markdown: {
  rehypePlugins: [
    [rehypePicture, { publicDir: new URL('./public', import.meta.url).pathname }],
  ],
},

The publicDir option specifies the actual path to the image files.

Image Conversion Logic

Let’s look at the core logic related to image conversion in rehype-picture.mjs.

Conversion Strategy by Format

The getVariants() function defined in rehype-picture.mjs determines which converted files to generate based on the original file extension.

function getVariants(ext) {
  const lower = ext.toLowerCase();
  switch (lower) {
    case '.jpg':
    case '.jpeg':
    case '.png':
      return [
        { ext: 'avif', format: 'avif', mime: 'image/avif', animated: false },
        { ext: 'webp', format: 'webp', mime: 'image/webp', animated: false },
      ];
    case '.gif':
      return [
        { ext: 'webp', format: 'webp', mime: 'image/webp', animated: true },
      ];
    default:
      return [];
  }
}
Original FormatGenerated FormatDescription
JPG/PNGAVIF + WebPStatic images. AVIF is smaller but less compatible, so both provided
GIFWebPAnimated GIFs are converted to animated WebP
SVG-Vector images, no conversion needed
External URL-Only local files are processed

Conversion with Sharp

The actual image conversion uses the Sharp library. The ensureVariant() function generates AVIF or WebP converted files from the original image, and includes caching logic to skip if the file has already been generated.

async function ensureVariant(srcPath, destPath, format, isAnimated) {
  const key = destPath;
  if (generationCache.has(key)) return;
  generationCache.add(key);

  if (existsSync(destPath)) return;

  try {
    let pipeline = sharp(srcPath, { animated: isAnimated });
    if (format === 'avif') {
      pipeline = pipeline.avif({ quality: 50 });
    } else if (format === 'webp') {
      pipeline = pipeline.webp({ quality: 80 });
    }
    await pipeline.toFile(destPath);
  } catch {
    // Silently skip on failure (use original image)
  }
}
  • AVIF: Set to quality 50. AVIF shows minimal visual difference even at lower quality, allowing significant file size reduction
  • WebP: Set to quality 80. A fallback for broader browser support
  • animated: Preserves animation when converting GIF to WebP

When to Skip

Not all images need to be converted. The shouldSkip() function determines which images to exclude from conversion. External URLs can’t be converted since they’re not local files, data URIs are already inline data, and SVGs are vector images — converting them to raster formats would actually degrade quality.

function shouldSkip(src) {
  if (!src) return true;
  if (src.startsWith('http://') || src.startsWith('https://')) return true;
  if (src.startsWith('data:')) return true;
  if (src.toLowerCase().endsWith('.svg')) return true;
  return false;
}

How the Plugin Finds and Converts Images in Markdown

The rehype plugin works with the result of converting markdown to HTML as a tree structure. This tree is called HAST (Hypertext Abstract Syntax Tree), where each HTML tag is represented as a JavaScript object (node). The plugin traverses this tree, finds <img> nodes, and replaces them with <picture>.

Since images in markdown are represented in the tree in two different ways, each requires different handling.

Case 1: Images Written in Markdown Syntax

Images written with ![alt](src) syntax are parsed as element-type <img> nodes in the HAST tree. In this case, we can directly access the node’s attributes (properties.src) for processing.

if (node.type === 'element' && node.tagName === 'img') {
  // Skip if already inside a <picture>
  if (parent?.type === 'element' && parent.tagName === 'picture') return;

  const src = node.properties?.src;
  // ... conversion processing
  parent.children[idx] = createPictureNode(node, result);
}

Case 2: Images Written Directly in HTML

When <img src="..."> tags are written directly in markdown, they remain as unparsed raw string nodes in the HAST tree. Since we can’t directly access node attributes in this case, we use regular expressions to extract <img> tags and process them through string replacement.

if (node.type === 'raw' && typeof node.value === 'string') {
  // Extract <img> tags with regex
  const imgMatches = [...node.value.matchAll(IMG_TAG_RE)];
  // ... convert to <picture> via string replacement
}

By handling both cases, all images are automatically optimized regardless of whether they’re written in markdown syntax or HTML tags.

Conversion Result

Before conversion:

<img src="/assets/images/example.jpg" alt="Example" />

After conversion:

<picture>
  <source srcset="/assets/images/example.avif" type="image/avif" />
  <source srcset="/assets/images/example.webp" type="image/webp" />
  <img
    src="/assets/images/example.jpg"
    alt="Example"
    width="800"
    height="600"
    loading="lazy"
    decoding="async"
  />
</picture>

The browser checks <source> elements from top to bottom and uses the first supported format. If it supports AVIF, it loads AVIF; otherwise WebP; if neither is supported, it loads the original JPG.

Build Performance Optimization

Since image conversion is a CPU-intensive task, two caching strategies are used for build performance.

Dimension Cache

const dimensionCache = new Map();

async function getDimensions(filePath) {
  if (dimensionCache.has(filePath)) return dimensionCache.get(filePath);
  const meta = await sharp(filePath).metadata();
  const dims = { width: meta.width, height: meta.height };
  dimensionCache.set(filePath, dims);
  return dims;
}

Caches image dimension information in memory to avoid repeated lookups. This is especially effective in a multilingual blog where the same image is used across 3 language files.

Generation Cache

const generationCache = new Set();

async function ensureVariant(srcPath, destPath, format, isAnimated) {
  const key = destPath;
  if (generationCache.has(key)) return; // Already requested
  generationCache.add(key);

  if (existsSync(destPath)) return; // File already exists
  // ... execute conversion
}

Two levels of caching prevent unnecessary conversions:

  1. generationCache: Checks if the conversion was already requested in the same build (memory)
  2. existsSync: Checks if the file was already generated in a previous build (filesystem)

LCP Image Preload

In Head.astro, the post’s featured image is preloaded as the LCP (Largest Contentful Paint) image.

{image && <link rel="preload" as="image" href="{image}" fetchpriority="high" />}

Since the featured image is the largest content element on the page, preloading it reduces LCP time. fetchpriority="high" tells the browser that this resource has high priority.

For the remaining images, the plugin automatically adds loading="lazy" and decoding="async" to prevent them from affecting initial load.

Conclusion

In this post, we looked at the custom rehype-picture plugin for the Astro blog.

  • Automatically converts markdown <img> elements to <picture> elements
  • Generates AVIF (quality 50) / WebP (quality 80) images using Sharp
  • Handles both markdown syntax images and directly written HTML images
  • Optimizes build performance with dimensionCache and generationCache
  • Improves loading performance with LCP image preload and lazy loading

In the next post Comments System and Ad Integration, we’ll cover how to integrate the Utterances comment system and Google AdSense ads.

Series Guide

This post is part of the Jekyll to Astro migration series.

  1. Why I Migrated from Jekyll to Astro
  2. Astro Installation and Project Setup
  3. Content Collections and Markdown Migration
  4. Multilingual (i18n) Implementation
  5. SEO Implementation
  6. Image Optimization — Custom rehype Plugin
  7. Comment System (Utterances)
  8. Ad Integration (Google AdSense)
  9. Search Implementation with Pagefind
  10. Layout and Component Architecture
  11. GitHub Pages Deployment
  12. Social Share Automation Script
  13. Troubleshooting and Tips

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS