React

Optimised images in Astro: Image, Picture and format

In Astro images use two components: Image builds widths and one format, Picture offers AVIF and WebP with a fallback, and public/ skips the pipeline.

Key points

  • The Image component reads the imported file, builds the srcset and writes width and height into the HTML.
  • Picture declares several formats plus a fallback: the browser takes the first it can read.
  • A file in public/ skips the pipeline and is copied as it is.

In Astro you optimise images through the Image component from astro:assets: it converts the format, generates the requested widths and writes width and height into the HTML, which is what stops the layout from shifting.

What the Image component does

A file imported from src/assets is read at build time: widths produces a srcset, sizes tells the browser which one to pick and format chooses the codec. None of that happens for a file in public/, which is copied as it is.

npx astro sync
---
import { Image } from "astro:assets";
import hero from "../assets/hero.jpg";
---

With Picture you declare several formats and a fallback: the browser takes the first it can read, so AVIF where it exists and JPEG elsewhere. The markup already ships a source and an img.

---
import { Picture } from "astro:assets";
import hero from "../assets/hero.jpg";
---

Remote images and folders

An image on another domain has to be allowed in the configuration, otherwise Astro rejects it at build time. The folder matters: src/assets goes through the pipeline, public/ does not.

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  image: {
    domains: ["cdn.example.com"],
  },
});

Layout and reserved space

The layout prop writes an aspect ratio and responsive sizes: with constrained the image never exceeds the column, with full-width it fills the page. In both cases the space is reserved before loading.

node -e "const fs=require('fs');const d='public/blog-covers/it';const s=fs.readdirSync(d).filter(f=>f.endsWith('.png')).map(f=>fs.statSync(d+'/'+f).size);const t=s.reduce((a,b)=>a+b,0);console.log(s.length,(t/1048576).toFixed(2)+'MB',(t/s.length/1024).toFixed(1)+'KB')"

The number to watch is not the mean but the tail: the largest cover stays below 78 KB, so the whole archive fits an 80 KB per-file budget. A low mean with a 400 KB tail tells a different story, and the distribution chart shows it before the total does.

One generated image for sharing

---
import { getImage } from "astro:assets";
import cover from "../assets/cover.jpg";

const og = await getImage({ src: cover, width: 1200, height: 630, format: "png" });
---

getImage returns an object with src and the computed attributes: it is the tool when the image does not live in an img tag, such as the og:image meta or a background-image. The 1200×630 size is the one social previews read without cropping.

The decision rule

Rule: a file moves to src/assets when the layout decides the width; it stays in public/ when it is already at final size and never changes, such as a favicon or a logo. The discriminator is who knows the container: the component does, the stylesheet does not.

The mistake to avoid is a width scale copied from another project: [320, 640, 960, 1280, 1600] on an image the layout shows at 320 px produces four files no browser will request. Start from the real maximum width and halve it.

The limitation to plan for

Conversion costs build time: every width is one more file, and a page with twenty images across five widths produces a hundred files. On hosting that keeps no build cache the first load is slower than expected.

Mistakes you will see

  • Importing a file from public/: the path is a string, the component cannot measure it and generates nothing.
  • Forgetting alt: the component compiles, but the image stays silent for anyone using a screen reader.
  • Dropping the fallback after choosing AVIF: browsers that cannot read it show an empty box.
  • Hand-writing width on a generated image: the component overwrites the value.
  • Declaring a generic sizes when the image fills half a column: the browser downloads the wrong width and the srcset does nothing.

Catalogue images are browsed by category: image collects the components, saastro brings the ready examples, and the hero block is where a large image weighs most. Content collections are in Astro content collections, hydration in interactive islands in Astro, the same problem in another framework in images with next/image, the comparison in Astro vs Next.js.

Frequently asked questions

Do I have to move every image into src/assets?

Only the ones you want converted: a file in src/assets goes through the pipeline and gets a srcset and dimensions. An image in public/ stays served as it is, useful for favicons and files already optimised.

AVIF or WebP?

AVIF compresses better but takes longer to encode and is not read by very old browsers. The safe choice is both, with Picture and a JPEG fallback covering the rest.

Why has the build become slow?

Because every width and every format is a file to generate. Trim the widths list to the ones the layout really uses and the build returns to a size in proportion to the images.

How do I handle images from a CDN?

By adding the domain to image.domains or remotePatterns in the configuration. Astro downloads the original at build time and generates the variants: the CDN only serves the starting file.

Image or Picture?

Image when one format is enough, Picture when you want AVIF and WebP with a fallback. Picture produces more files, so you need a reason to pay for it.