TL;DR

Astro's built-in image optimization -- the <Image /> and <Picture /> components backed by sharp -- is the lowest-effort, highest-impact Core Web Vitals win we deploy on client projects. A KD 2 topic for a reason: it's nearly free, it works at build time, and it shaves 40-60% off image payload without touching a CDN. This article covers everything we've learned shipping it across dozens of Astro sites.

What Is Astro Image Optimization?

Astro image optimization is the built-in system that processes, compresses, and converts images at build time using the astro:assets module. It ships with Astro core -- no separate integration since 3.0 -- and automatically converts images to WebP, sets width and height attributes to prevent layout shift, and adds loading="lazy" and decoding="async" by default.

Before 3.0, image optimization lived in the @astrojs/image integration. Fred Schott wrote about that transition on the Astro blog, noting the goal was to "make image optimization effortless." Having used both the old integration and the current built-in system, we can confirm: the current version delivers. You import an image, drop it in a component, and the build handles the rest.

The system exposes three primary APIs:

  1. <Image /> -- renders an optimized <img> tag
  2. <Picture /> -- renders a <picture> element with multiple <source> elements for format negotiation
  3. getImage() -- a function that returns optimized image metadata without rendering HTML

How Does Astro's Image Pipeline Work Under the Hood?

Sharp. That's the short answer. Astro uses sharp (v0.33.x as of Astro 5.x) as its default image service. Sharp is a high-performance Node.js binding to libvips, and it handles resizing, format conversion, and compression during astro build.

Here's what happens when you use <Image />:

  1. Import resolution -- When you import hero from '../assets/hero.jpg', Astro's Vite plugin intercepts the import and resolves it as an ImageMetadata object containing the original width, height, format, and file path.
  2. Build-time processing -- During astro build, sharp reads the source file, resizes it to the specified dimensions (or the original dimensions if none are specified), converts it to the target format (WebP by default), and writes the output to _astro/ in your build directory.
  3. HTML output -- The component renders a standard <img> tag with the optimized file path, explicit width and height, loading="lazy", and decoding="async".

During astro dev, images are processed on-demand per request. This means dev server startup stays fast regardless of how many images your project has.

The current image config in Astro 4.x and 5.x is simpler than the old integration:

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

export default defineConfig({
  image: {
    service: { entrypoint: 'astro/assets/services/sharp' },
  },
});

You don't need to specify this at all unless you're swapping to a different service. Sharp is the default.

Image vs Picture: Which Component Should You Use?

<Image /> is the right choice 90% of the time. <Picture /> is for when you need format negotiation across browsers or art direction with different crops at different breakpoints.

``

---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---

<Image src={hero} alt="Product dashboard showing real-time analytics" />

Output: a single <img> tag pointing to a WebP file. Width and height are inferred from the import.

Custom dimensions:

<Image src={hero} alt="Product dashboard" width={800} height={450} />

Astro will resize the image to 800×450 during build. If you only specify width, Astro calculates height from the original aspect ratio.

``

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

<Picture
  src={hero}
  formats={['avif', 'webp']}
  alt="Product dashboard showing real-time analytics"
/>

Output: a <picture> element with <source> tags for AVIF and WebP, plus a fallback <img> in the original format (JPEG). The browser picks the best format it supports.

When to use <Picture />:

  • You want AVIF delivery for browsers that support it (Chrome 85+, Firefox 93+) with WebP fallback
  • You need different image crops at different breakpoints via the media attribute on sources
  • You're optimizing hero images where AVIF's ~20% size advantage over WebP matters

When to stick with <Image />:

  • Blog post images, thumbnails, team photos -- anything below the fold
  • When build time matters (each format in <Picture /> generates a separate file)
  • When the added HTML weight of <picture> isn't justified
Feature <Image /> <Picture />
Output HTML <img> <picture> with <source> elements
Multiple formats No (single output format) Yes (AVIF + WebP + fallback)
Art direction No Yes (via media on sources)
Build cost 1 file per image N files per image (one per format)
Default format WebP Original + specified formats

Local Images vs Remote Images

Local images live in your src/ directory. Remote images come from external URLs. The optimization behavior differs significantly.

Local Images

Place images in src/assets/ (or any subdirectory under src/). Import them:

---
import { Image } from 'astro:assets';
import photo from '../assets/team/sarah.jpg';
---

<Image src={photo} alt="Sarah, lead engineer" />

Astro processes these at build time. Width, height, and format are all known statically. This is the happy path -- you get full optimization with zero configuration.

Important: Images in public/ are NOT optimized. They're copied as-is to the build output. We see teams put images in public/ because that's what they did in Next.js or plain HTML projects. In Astro, that bypasses the entire pipeline. Only put images in public/ if you explicitly don't want optimization (favicons, OG images you've already pre-optimized, etc.).

Remote Images

Remote images require explicit width and height because Astro can't introspect the file at build time without fetching it:

---
import { Image } from 'astro:assets';
---

<Image
  src="https://images.unsplash.com/photo-123?w=1200"
  alt="Mountain landscape"
  width={1200}
  height={800}
/>

If you set inferSize={true}, Astro will fetch the remote image during build to determine dimensions. This adds network requests to your build, which can slow things down with many remote images:

<Image
  src="https://images.unsplash.com/photo-123"
  alt="Mountain landscape"
  inferSize
/>

Remote images are only optimized if the domain is in your allowlist (see next section). Otherwise, Astro still renders the <img> tag with proper width/height/loading/decoding attributes -- preventing CLS -- but does not reprocess the image bytes.

The Domains and RemotePatterns Allowlist

Astro requires you to explicitly authorize which remote domains can be optimized. This is a security measure -- you don't want your build server downloading and processing arbitrary URLs.

// astro.config.mjs
export default defineConfig({
  image: {
    domains: [
      'images.unsplash.com',
      'res.cloudinary.com',
      'cdn.sanity.io',
    ],
  },
});

For more granular control, use remotePatterns:

export default defineConfig({
  image: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.supabase.co',
        pathname: '/storage/v1/object/public/**',
      },
    ],
  },
});

This is particularly relevant if you're pulling images from Supabase Storage. Public bucket URLs follow the /storage/v1/object/public/ pattern, and the wildcard hostname pattern **.supabase.co covers project-specific subdomains like yourproject.supabase.co.

Similarly, if you're using Cloudinary for user-uploaded images, you'd add res.cloudinary.com to your domains list. The upload pipeline is separate from optimization; Astro handles the optimization at build time regardless of how the image got to Cloudinary.

Layout, Widths, and Densities: Responsive Image Control

Astro 5.x introduced the layout, widths, and densities props for responsive image generation. These are the most under-documented features in Astro's image system, and they're critical for real-world responsive design.

The `layout` Prop

<Image
  src={hero}
  alt="Hero banner"
  layout="responsive"
  widths={[400, 800, 1200, 1600]}
/>

The layout prop accepts:

  • responsive -- image scales with container width, generates srcset with multiple widths
  • fixed -- image stays at specified dimensions, generates srcset for density descriptors (1x, 2x)
  • full-width -- like responsive but assumes the image spans the viewport; sets sizes="100vw"
  • none -- no responsive behavior, single image output (default)

You can also configure a default layout globally:

// astro.config.mjs
export default defineConfig({
  image: {
    experimentalLayout: 'responsive',
  },
});

The `widths` Prop

Specify exact pixel widths for srcset generation:

<Image
  src={hero}
  alt="Hero"
  widths={[400, 800, 1200]}
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
/>

This generates three optimized files and a proper srcset. You must pair widths with sizes for the browser to pick the right image. Without sizes, the browser defaults to 100vw and may download a larger image than needed.

The `densities` Prop

For fixed-size images (icons, avatars, logos), use density descriptors:

<Image
  src={avatar}
  alt="User avatar"
  width={48}
  height={48}
  densities={[1, 2, 3]}
/>

This generates 48px, 96px, and 144px versions with 1x, 2x, 3x descriptors. Retina displays get the sharper version; standard displays save bandwidth.

The key insight: widths is for fluid/responsive images, densities is for fixed-dimension images. Don't combine them on the same component.

Using getImage() for Advanced Cases

getImage() returns optimized image metadata without rendering any HTML. This is essential when you need the optimized URL but control the markup yourself.

---
import { getImage } from 'astro:assets';
import bg from '../assets/bg-pattern.png';

const optimizedBg = await getImage({
  src: bg,
  width: 1920,
  format: 'webp',
  quality: 80,
});
---

<div style={`background-image: url('${optimizedBg.src}')`}>
  <!-- content -->
</div>

Real use cases we've shipped with getImage():

  • CSS background images -- as shown above
  • OG image generation -- pass the optimized src into meta tags
  • Dynamic image maps -- generate a lookup object of optimized URLs for client-side JavaScript
  • Email templates -- where you need absolute URLs to pre-optimized images
  • Custom <picture> markup -- when the built-in <Picture /> component doesn't match your HTML requirements

getImage() returns an object with src, attributes (containing width, height, loading, decoding), and srcSet if applicable. The attributes object is spread-friendly:

---
const optimized = await getImage({ src: hero, width: 600 });
---

<img src={optimized.src} {...optimized.attributes} alt="Hero" />

Real LCP and CLS Guidance

Image optimization isn't academic -- it directly affects Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS), two of the three Core Web Vitals that Google uses for ranking.

LCP: The Hero Image Problem

Your hero image is almost always your LCP element. Here's what we do on every Astro project:

  1. Remove loading="lazy" from the hero. Astro adds loading="lazy" by default. Override it:
<Image src={hero} alt="Hero" loading="eager" />
  1. Add fetchpriority="high":
<Image src={hero} alt="Hero" loading="eager" fetchpriority="high" />
  1. Preload the hero image. In your layout's <head>:
<link rel="preload" as="image" href="/path/to/optimized-hero.webp" />

With getImage(), you can generate this path dynamically:

---
import { getImage } from 'astro:assets';
import heroSrc from '../assets/hero.jpg';
const hero = await getImage({ src: heroSrc, width: 1200, format: 'webp' });
---

<head>
  <link rel="preload" as="image" href={hero.src} />
</head>
  1. Size the hero correctly. Don't serve a 3000px image for a 1200px container. Measure your layout's max content width and size accordingly.

On a recent project, these four steps dropped LCP from 3.8s to 1.4s on mobile 4G. The single biggest factor was removing loading="lazy" -- the browser was waiting until the image scrolled into view to even start the request.

CLS: Why Width and Height Matter

Astro's <Image /> component always includes width and height attributes. This is non-negotiable for CLS. The browser reserves space for the image before it loads, preventing content from jumping.

If you're using raw <img> tags (for images you don't want Astro to optimize), always include explicit dimensions. A CLS score above 0.1 fails Core Web Vitals. A single hero image without dimensions can push you over that threshold.

For more detail on how we approach this across frameworks, see our Core Web Vitals optimization service page.

When Should You Push Images to a CDN Instead?

Astro's build-time optimization is excellent for static and SSG sites. But it has real limitations:

  • User-uploaded images -- If your app accepts image uploads (profile photos, product images), you can't optimize them at build time because they don't exist yet. You need a runtime image CDN.
  • Thousands of images -- We've seen Astro builds with 2,000+ images take 15+ minutes just for image processing. At some point, you're better off offloading to Cloudinary, Imgix, or Cloudflare Images.
  • SSR with dynamic images -- In SSR mode, Astro can optimize at request time, but this adds latency to each response. A CDN with edge caching is faster.
  • Global delivery -- Astro builds to static files served from your host's CDN. That handles geographic distribution. But if your host doesn't have a good edge network, an image-specific CDN with 200+ PoPs (like Cloudinary or Imgix) will deliver faster.

For marketing sites and blogs where all images are known at build time, Astro's built-in pipeline is sufficient -- and free. For user-generated content, you need a different architecture.

For a comparison of how Next.js handles the same problem (with its runtime-first approach vs Astro's build-time approach), see our Next.js image optimization guide.

Configuration Reference: astro.config.mjs

Here's every image-related config option in one place:

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

export default defineConfig({
  image: {
    // Image service (sharp is default, no need to specify unless swapping)
    service: { entrypoint: 'astro/assets/services/sharp' },

    // Authorized remote image domains
    domains: ['images.unsplash.com', 'res.cloudinary.com'],

    // Authorized remote image URL patterns
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.supabase.co',
        pathname: '/storage/v1/object/public/**',
      },
    ],

    // Default responsive layout (Astro 5.x)
    experimentalLayout: 'responsive',
  },
});

Sharp-Specific Options

You can pass options to sharp via the service config:

service: {
  entrypoint: 'astro/assets/services/sharp',
  config: {
    // sharp-specific options
    limitInputPixels: false, // allow very large images
  },
},

Per-Component Quality Control

The quality prop accepts a number (1-100) or named presets:

<Image src={photo} alt="Photo" quality={80} />
<Image src={photo} alt="Photo" quality="mid" />

Named presets: low (25), mid (50), high (75), max (100). We typically use 75-85 for photos and 90+ for images with text.

Format Control

<Image src={photo} alt="Photo" format="avif" />

Supported formats: webp (default), avif, png, jpg, svg, gif. AVIF produces smaller files than WebP but takes 5-10× longer to encode during build. On a site with 200 images, AVIF encoding added 4 minutes to our build. Use it selectively -- hero images, above-the-fold content -- not site-wide.

FAQ

Does Astro image optimization work in SSR mode?

Yes. In SSR mode with an adapter (Vercel, Netlify, Node), images are optimized at request time rather than build time. The first request is slower, but subsequent requests can be cached. For high-traffic SSR sites, pair this with a CDN layer.

What happens if I don't specify width and height for a local image?

Astro infers both from the imported file's metadata. You only need to specify dimensions when you want to resize. For remote images without inferSize, width and height are required -- Astro will throw a build error if they're missing.

Can I use Astro's image optimization with MDX content?

Yes. In .mdx files, you can import and use the <Image /> component directly. Standard Markdown image syntax (![alt](./path.jpg)) in .md files also gets optimized if the path points to a src/ directory image.

How does Astro image optimization compare to Next.js Image?

Next.js optimizes at request time by default and caches on the server. Astro optimizes at build time. Astro's approach means zero runtime cost and no server-side image processing in production. Next.js is better for dynamic, user-uploaded images. We wrote a detailed comparison in our Next.js image optimization guide.

Does Astro support responsive images with srcset automatically? Not by default. You need to use the widths or densities prop, or set experimentalLayout in your config. Without these, Astro outputs a single <img> tag with one source file. The <Picture /> component generates multiple formats but not multiple sizes unless you add widths.

Should I use AVIF or WebP as the default format?

WebP is the safer default. It has near-universal browser support (97%+ as of 2025) and fast encode times. AVIF produces 20% smaller files but encoding is significantly slower. Use AVIF selectively via <Picture formats={['avif', 'webp']} /> for critical images, and let the browser negotiate.

What's the difference between putting images in src/ vs public/? Images in src/ are processed by Astro's image pipeline -- optimized, converted, and fingerprinted. Images in public/ are copied verbatim to the output with no processing. Use public/ only for images you've already optimized externally or that need a stable URL (like favicon.ico).