Astro Image and Picture Components

Astro’s image story is deliberately compile-first: the astro:assets module treats every local image as a typed module import, hands it to an image service (Sharp by default) during the build, and emits hashed, correctly-sized derivatives with the intrinsic width and height baked into the markup so layout shift is impossible by construction. This page is part of Framework & Build-Tool Media Integration and covers the whole surface: the <Image> and <Picture> components, the built-in Sharp service (plus the squoosh and passthrough alternatives), the image configuration block that authorises remote hosts, the difference between densities and widths, the getImage() escape hatch for non-<img> contexts, and how content-collection images differ from ones dropped in src/assets. It sits alongside the Nuxt and Vite image asset pipeline as the third major build-time model in this section.

Concept & architecture

The image service abstraction

Everything in astro:assets routes through an image service — a small adapter with two jobs: compute the transform URL/parameters, and (for local images) perform the actual encode. Astro ships three:

  • Sharp (default) — libvips-backed, fast, produces AVIF and WebP. This is what you want in almost every case.
  • squoosh — a WebAssembly encoder used historically when Sharp could not install in a constrained environment. Slower and lower-quality AVIF; retained for compatibility.
  • passthrough / noop — performs no optimisation. Used when a downstream host (Netlify, Vercel image CDN) owns the transform, so Astro just emits the original plus the correct markup.

For a local image the service runs at build time and writes files into dist/_astro/. For a remote image, Astro cannot read the bytes at build time in the general case, so the component emits a service URL and the transform happens on demand (via an adapter’s image endpoint) — which is exactly why remote hosts must be explicitly authorised before Astro will optimise them.

The abstraction is deliberately small, and knowing its shape explains most of the behaviour you observe. A local service implements getURL(), getHTMLAttributes(), getSrcSet(), validateOptions(), parseURL(), and transform(). An external service (what a CDN integration ships) implements everything except transform() — because the bytes are never touched by your build; getURL() simply composes a provider URL such as https://res.cloudinary.com/…/w_800,f_avif/hero.jpg. That split is the reason a passthrough service costs nothing at build time while Sharp costs real CPU: only the local variety ever decodes pixels.

Two consequences fall straight out of that design. First, validateOptions() runs before any encoding, so an impossible combination (densities together with widths, an alt that is missing, a remote src with no dimensions) fails the build with a precise error rather than producing a broken asset. Second, getSrcSet() — not the component — owns the candidate list, which is why <Image> and <Picture> produce identical srcset strings for the same widths/densities input and differ only in how many <source> elements wrap them.

Warning: files placed in public/ bypass the abstraction entirely. Astro copies public/ verbatim into dist/, so <Image src="/hero.jpg" …> referencing a public asset is treated as a remote-style string URL: no resize, no format conversion, no intrinsic dimensions. Only src/assets imports (and content-collection fields declared with image()) enter the service. If a project’s images “aren’t being optimised at all”, check which directory they live in before touching any config.

Astro build-time image service flow Local images imported from src/assets and content collections flow into the Sharp image service which writes hashed derivatives to dist/_astro. Remote images are checked against the domains and remotePatterns allowlist before being optimised. Both paths produce Image or Picture markup with intrinsic width and height. Local import src/assets/*.jpg collection images Remote URL https://cdn/… string src host in domains / remotePatterns? yes no → passed through unoptimised Image service sharp (default) squoosh · passthrough resize · re-encode → dist/_astro/*.hash Markup <Image> → img <Picture> → picture intrinsic w/h set avif · webp · fallback

<Image> vs <Picture>

<Image> renders exactly one <img>. It optimises to a single output format (WebP by default) and produces a srcset when you pass widths or densities. Use it when one format is enough — typically WebP, which every modern browser decodes.

<Picture> renders a <picture> with one <source> per entry in its formats prop, plus a fallback <img>. Use it when you want an AVIF-first cascade with a WebP and original fallback — the multi-format art-direction case. The formats={['avif','webp']} cascade and its Safari edge cases are covered in depth in Astro Picture component for AVIF and WebP.

The practical decision is narrower than it looks. <Image format="webp"> is the right default for body content: WebP is decoded by every browser still receiving security updates, one format means one encode per width, and the markup stays a single <img> that CSS and JavaScript can address without a wrapper. Reach for <Picture> when the byte delta actually matters — a full-bleed hero, a gallery, anything that is the LCP element — because that is where AVIF’s extra 25-35% saving over WebP repays the doubled encode count. The comparison data behind that threshold is in when to use WebP over JPEG in production.

Quality presets and what they encode to

quality accepts either a number (0-100) or one of four named presets. The presets are not a single number applied to every codec: the Sharp service maps each preset per output format, because a JPEG at q=50 and an AVIF at q=50 are nowhere near the same perceptual quality. Using the preset names keeps that mapping intact when you change format later.

Preset JPEG / PNG WebP AVIF Typical use
low ~25 ~25 ~15 Thumbnails, blurred backdrops, anything below 200px
mid ~50 ~50 ~40 Body content, cards, the safe default
high ~80 ~80 ~65 Hero images, photography-led pages
max ~100 ~100 ~90 Screenshots with fine text, product detail zooms

Tradeoff: hard-coding quality={80} looks safer than quality="high" but is not. If you later switch a component from format="webp" to format="avif", the literal 80 becomes an AVIF quality of 80 — roughly a third larger than the AVIF the preset would have picked, for no visible gain. Numbers are for one-off tuning against a measured file; presets are for components that may change format.

Configuration reference

Setting / prop Where Meaning
image.service astro.config.mjs Selects the backend: sharp (default), squoosh (legacy WASM), or a passthrough service.
image.domains astro.config.mjs Exact remote hostnames Astro is allowed to optimise (e.g. ['images.unsplash.com']).
image.remotePatterns astro.config.mjs Pattern-based remote allowlist (protocol, hostname with wildcards) for many origins.
widths <Image>/<Picture> Explicit pixel widths for the srcset; pair with sizes. Best for fluid layouts.
densities <Image>/<Picture> DPR multipliers ([1, 2]) generating a 1x/2x srcset. Best for fixed-size images.
sizes <Image>/<Picture> The layout-width hint the browser uses to choose a widths candidate. Required with widths.
format <Image> Single output format for <Image> (avif, webp, png, jpg).
formats <Picture> Ordered list of <source> formats (['avif','webp']), smallest first.
quality both Encoder quality: a number, or a named preset (low, mid, high, max).
priority both Sets loading="eager", decoding="sync", and fetchpriority="high" together for the LCP image.
getImage() astro:assets Programmatic API returning { src, srcSet, attributes } for non-<img> uses (CSS backgrounds, <link rel=preload>).

Tradeoff: densities and widths are mutually exclusive on a single component. densities={[1, 2]} produces exactly 1x/2x candidates sized off the base width — perfect for an avatar or icon with a fixed CSS box. widths={[400, 800, 1200]} produces width-descriptor candidates the browser matches against sizes — correct for a fluid image that spans different fractions of the viewport. Mixing them is a config error; choose based on whether the rendered size is fixed or fluid.

densities versus widths: what each generates Two panels compare the two srcset strategies. The densities panel takes a base width of 96 pixels and emits 1x and 2x candidates chosen by device pixel ratio with no sizes attribute. The widths panel takes 400, 800 and 1200 pixel candidates with width descriptors, which the browser resolves through the sizes attribute and the device pixel ratio. The two props are mutually exclusive on one component. densities={[1, 2]} fixed CSS box · DPR descriptors width={96} height={96} 96 px → 1x 192 px → 2x emitted srcset avatar.h1.webp 1x, avatar.h2.webp 2x chosen by devicePixelRatio only no sizes attribute required widths={[400, 800, 1200]} fluid layout · width descriptors sizes="(max-width:800px) 100vw, 1200px" 400w 800w 1200w emitted srcset hero.h.webp 400w, 800w, 1200w sizes → CSS px, × DPR, then the smallest candidate ≥ that width Mutually exclusive: a component may set densities or widths, never both.

The resolution rule that panel encodes is worth stating precisely, because it is where most “why did it fetch the 1200w file on a phone?” reports come from. With width descriptors the browser computes an effective required width — the CSS pixel width implied by sizes for the current viewport, multiplied by devicePixelRatio — then selects the smallest candidate greater than or equal to it. A 390 CSS-pixel phone at DPR 3 needs 1170 device pixels, so it will legitimately take the 1200w candidate; that is correct behaviour, not a bug. With density descriptors there is no sizes step at all: the browser reads devicePixelRatio, rounds, and takes the matching 1x/2x entry regardless of layout. That is precisely why densities on a fluid image under-serves large viewports — the candidate set never grows past width × max(densities).

Step-by-step

Step 1 — Configure the image service and remote allowlist

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

export default defineConfig({
  image: {
    // service selects the encoder. Sharp is the default and rarely needs
    // changing; passing squoosh explicitly is only for environments where
    // Sharp's native binary cannot install.
    service: { entrypoint: 'astro/assets/services/sharp' },

    // domains authorises EXACT remote hostnames. A remote <Image src="https://…">
    // whose host is not listed here (or in remotePatterns) is emitted UNOPTIMISED
    // — Astro will not fetch and re-encode an arbitrary origin for you.
    domains: ['images.unsplash.com'],

    // remotePatterns authorises hosts by pattern — use for a whole CDN.
    // protocol/hostname wildcards avoid listing every subdomain by hand.
    remotePatterns: [{ protocol: 'https', hostname: '**.cdn.example.com' }],
  },
});

Step 2 — Import and render a local image with <Image>

---
// component.astro — frontmatter (build-time) section
import { Image } from 'astro:assets';
// Local images are IMPORTED, not referenced by string. The import is a typed
// object carrying intrinsic width/height/format — Astro uses it to set the
// <img> dimensions automatically, so no layout shift is possible.
import hero from '../assets/hero.jpg';
---

<!--
  widths + sizes = a responsive srcset for a fluid image.
  format="webp" emits a single WebP <img> (broad support, no fallback needed).
  Astro fills width/height from the import; you may override to change the box.
-->
<Image
  src={hero}
  alt="Studio product hero"
  widths={[400, 800, 1200]}
  sizes="(max-width: 800px) 100vw, 1200px"
  format="webp"
  quality="mid"
/>

Step 3 — Render a fixed-size image with densities

---
import { Image } from 'astro:assets';
import avatar from '../assets/avatar.png';
---

<!--
  densities is the right tool for a fixed-size image: the CSS box is 96px,
  so we only need 1x and 2x candidates. width sets the base; densities
  multiplies it. Do NOT also pass widths — the two are mutually exclusive.
-->
<Image
  src={avatar}
  alt="Author avatar"
  width={96}
  height={96}
  densities={[1, 2]}
  format="webp"
/>

Step 4 — Handle a remote image

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

<!--
  Remote images are passed as a STRING src. width and height are REQUIRED
  here (Astro cannot read the file to infer them at build time). The host
  MUST appear in image.domains or image.remotePatterns or the image is
  served unoptimised at its original bytes.
-->
<Image
  src="https://images.unsplash.com/photo-123"
  alt="Editorial landscape"
  width={1200}
  height={800}
  inferSize={false}
  format="webp"
/>

Step 5 — Use getImage() for non-<img> contexts

---
// getImage() returns the same optimisation result as <Image>, but as data
// you place yourself — e.g. a CSS background or a preload hint. It does NOT
// render an element, so you own the markup.
import { getImage } from 'astro:assets';
import bg from '../assets/texture.jpg';

const optimized = await getImage({
  src: bg,
  format: 'avif',
  width: 1600,
});
// optimized.src        → "/_astro/texture.<hash>.avif"
// optimized.srcSet.attribute → the srcset string
// optimized.attributes → { width, height, ... }
---

<!-- Preload the LCP background so it is fetched before CSS discovers it. -->
<link rel="preload" as="image" type="image/avif" href={optimized.src} />
<div style={`background-image:url(${optimized.src})`} class="hero-bg"></div>

Tradeoff: content-collection images (declared with the image() schema helper in src/content/config.ts) behave like src/assets imports — they are validated and optimised at build time. But an image referenced only by a string path in frontmatter (not through the image() schema) is treated as a plain public asset and is not optimised. If collection images ship at full size, the schema is using z.string() where it should use image().

Content collections and typed image fields

Content collections are where Astro’s build-time model earns its keep — and where the most common “why isn’t my image optimised” bug lives. A collection entry (a Markdown or MDX file, or a data entry) can reference an image in its frontmatter, but Astro only optimises it if the schema declares that field with the image() helper rather than a plain string.

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  // The image() helper is injected into the schema context. It resolves the
  // frontmatter path RELATIVE TO THE ENTRY FILE and returns the same typed
  // image object a normal import would — with intrinsic width/height — so
  // <Image>/<Picture> can optimise it.
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      // cover uses image(): the referenced file is validated at build time
      // (a missing file fails the build) and becomes an optimisable asset.
      cover: image(),
      // A plain z.string() here would NOT be optimised — Astro would treat
      // the value as an opaque URL string and ship the original bytes.
      externalCover: z.string().url().optional(),
    }),
});

export const collections = { blog };
---
// A page rendering a collection entry's cover image.
import { getCollection } from 'astro:content';
import { Picture } from 'astro:assets';

const posts = await getCollection('blog');
const post = posts[0];
---

<!--
  post.data.cover is a typed image object (because the schema used image()),
  so <Picture> optimises it exactly like a src/assets import — no special
  handling. If cover were a z.string(), this would render the raw file.
-->
<Picture
  src={post.data.cover}
  formats={['avif', 'webp']}
  widths={[400, 800, 1200]}
  sizes="(max-width: 800px) 100vw, 1200px"
  alt={post.data.title}
/>

Tradeoff: the image() schema helper validates paths at build time — a typo in a post’s frontmatter fails the whole build rather than silently rendering a broken image in production. That strictness is the point: it moves a class of content error left, into CI. The cost is that authors must keep referenced images inside the project tree; a frontmatter path pointing at a URL belongs in a separate z.string().url() field and is handled as a remote image instead.

Responsive layout modes and the layout prop

Hand-writing widths and sizes for every component is the part of the API people get wrong most often, so Astro added a layout mode that derives both. Setting image.layout in the config (or layout on an individual component) makes <Image> and <Picture> generate the candidate list and the sizes string from a declared layout intent, and attach the CSS that enforces it.

// astro.config.mjs — enable layout-driven responsive images project-wide
export default defineConfig({
  image: {
    // 'constrained' is the sane default: the image scales down with its
    // container but never renders above its intrinsic width.
    layout: 'constrained',
    // Emit the small inline style block that enforces the layout contract
    // (width:100%; height:auto; object-fit). Turn it off only if your own
    // CSS already owns those rules and you want to avoid a duplicate.
    responsiveStyles: true,
    // Widths Astro may pick from when it derives a srcset for a layout.
    // Trim this to your breakpoints — every entry is one more encode.
    breakpoints: [640, 750, 828, 1080, 1280, 1668, 2048],
  },
});
layout value Emitted CSS behaviour Derived sizes Use for
constrained width:100%; height:auto; max-width:<intrinsic> (min-width: Npx) Npx, 100vw Content images that shrink but never upscale
full-width width:100%; height:auto 100vw Full-bleed heroes and banners
fixed width:Npx; height:Npx density candidates only Avatars, logos, icons
none no CSS emitted none derived You own the CSS and pass widths/sizes yourself

Two companions matter once a layout is in play. fit (cover, contain, fill, inside, outside) decides how the source is mapped into the requested box when the aspect ratios differ, and position (center, top, entropy, attention, or an x y pair) decides which part survives a cover crop. entropy and attention are Sharp’s content-aware crop strategies: they pick the region with the most detail or the most likely subject, which is far better than a blind centre crop for user-uploaded photography where the subject is off-centre.

Warning: layout and explicit widths/sizes compete. If you pass layout="constrained" and a hand-written sizes, your sizes wins for candidate selection but Astro still emits the layout CSS — so an image can end up with a sizes contract that disagrees with the box the CSS actually gives it, and the browser will fetch the wrong candidate. Pick one mechanism per component: layout mode, or manual widths + sizes. If you are hand-writing them, the derivation method in how to calculate optimal sizes attribute values is the reliable route.

Build-time versus on-demand: SSR and the passthrough service

Everything above assumes a static build (astro build to HTML). Astro also supports server output via an adapter, and the image story changes subtly in that mode. With an SSR adapter installed, Astro exposes an image endpoint (/_image) that can perform transforms on demand — which is what makes remote-image optimisation and runtime resizing possible for content that did not exist at build time.

// astro.config.mjs — server output with an adapter enables the /_image endpoint
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  image: {
    service: { entrypoint: 'astro/assets/services/sharp' },
    // With SSR, an authorised remote image is transformed on first request by
    // the /_image endpoint and can be cached downstream by your CDN — set the
    // same immutable Cache-Control you would use for static hashed assets.
    domains: ['images.unsplash.com'],
  },
});

Warning: running the Sharp service on the /_image endpoint means the first request for a given size pays the encode cost synchronously, inflating TTFB for that request. Put a CDN in front and cache the endpoint responses aggressively, or the origin re-encodes on every cold cache miss. For a purely static site you avoid this entirely — every derivative is materialised in CI — which is the stronger default whenever your image set is known at build time.

When a downstream platform owns image optimisation (Netlify Image CDN, Vercel), select a passthrough service so Astro emits the original plus correct markup and defers the actual transform to the platform. This avoids double-encoding: you do not want Sharp producing an AVIF that the platform then re-transforms.

The /_image endpoint contract

In SSR mode the emitted src is no longer a hashed file path but a query against the endpoint, and the parameter names are short by design because they end up in every srcset candidate:

/_image?href=%2F_astro%2Fhero.Ab12Cd.jpg&w=1200&h=675&f=avif&q=60

href is the source (a project asset path, or an authorised remote URL); w/h are the target box; f is the output format; q is quality. The service’s parseURL() turns that string back into a transform options object and validateOptions() rejects anything outside the allowed set — which is what stops the endpoint from being used as an open resize proxy for arbitrary origins. Because every distinct parameter combination is a distinct cache key, the practical rule is the same one that governs any transform CDN: keep the parameter set small and canonical. Three widths and two formats is six keys per image; eight widths and three formats is twenty-four, and each one is a separate cold encode. The same immutable caching discipline described in best practices for setting max-age on CDN media assets applies to these URLs, because the parameters fully determine the bytes.

Where should the Astro image transform run? A decision tree. If every image is known at build time, use static output and let Sharp encode in CI. If not, check whether the host platform owns image transforms: if it does, use a passthrough service and let the platform CDN transform on request; if it does not, use an SSR adapter with the underscore image endpoint and cache its responses aggressively. Is every image known at build time? yes no output: 'static' astro build encodes it all does the platform own image transforms? yes no Sharp in CI writes dist/_astro/*.hash zero runtime cost slowest builds SSR adapter /_image transforms on first request, then caches cold TTFB risk passthrough Astro emits the original platform CDN resizes no double encode All three paths emit identical markup — only the place the encode happens changes.

Tradeoff: the three paths are not mutually exclusive across a project, only per image. A marketing site can ship its static hero art through Sharp in CI while user avatars, which arrive after deploy, go through the /_image endpoint. What you must not do is let two of them stack on one asset — Sharp producing an AVIF that a platform CDN then re-encodes to a second AVIF costs latency and quality for nothing, and it is invisible in the markup because both layers produce a valid <img>.

Parameter reference

Prop Component Notes
src both An imported module (local) or a string URL (remote). Local imports carry intrinsic dimensions.
alt both Required — Astro throws a build error if omitted, enforcing accessibility.
widths both Width-descriptor srcset; requires sizes.
densities both DPR srcset; mutually exclusive with widths.
formats <Picture> Ordered <source> formats, e.g. ['avif','webp'].
format <Image> Single output format.
quality both Number or preset name (low/mid/high/max).
priority both Bundles eager + sync decode + fetchpriority="high" for the LCP image.
inferSize remote <Image> true lets Astro fetch a remote image’s dimensions at build (extra request); default requires explicit width/height.
fallbackFormat <Picture> Format of the trailing <img>. Defaults to the source format; keep it universally decodable (jpg/png).
layout both constrained / full-width / fixed / none. Derives widths and sizes and emits the matching CSS.
fit both Sharp fit mode when the requested box’s ratio differs from the source: cover, contain, fill, inside, outside.
position both Crop anchor for fit="cover": an edge/corner keyword, or Sharp’s content-aware entropy / attention.
loading / decoding both Passed straight through to the <img>. priority sets both; otherwise loading defaults to lazy.
pictureAttributes <Picture> Attributes applied to the wrapping <picture> element rather than the inner <img>.

Tradeoffs & failure modes

Failure mode Cause Fix
<Image> throws “alt is required” at build Missing alt prop Add alt; use alt="" for decorative images
Remote image ships full-size Host not in domains/remotePatterns Add the origin to the allowlist
Build errors on missing width/height for remote Astro cannot infer remote dimensions Pass explicit width+height, or set inferSize={true}
densities and widths both set They are mutually exclusive Keep one: densities for fixed size, widths for fluid
Collection images not optimised Schema uses z.string() for the image field Use the image() schema helper so imports are typed and optimised
AVIF-only <Image> breaks older Safari Single-format <img> with no fallback Use <Picture formats={['avif','webp']}> for a cascade
Slow builds on a large gallery Sharp encoding many widths × formats Trim widths to real breakpoints; lower quality; the encode is build-time only
Image in public/ never optimised public/ is copied verbatim; it never enters the image service Move the file to src/assets and import it
Animated GIF/WebP renders as a still Sharp keeps only the first frame unless told otherwise Convert to muted looping <video>, or leave the asset in public/ untouched
SVG comes back rasterised or unchanged astro:assets does not resize vector sources Reference SVGs directly, or import them as components
Photo appears rotated 90° after build EXIF orientation dropped when the pixels are re-encoded Normalise orientation upstream in the asset pipeline before import
layout and hand-written sizes disagree Both mechanisms active on one component Use layout="none" when you supply widths/sizes yourself
Quality drop after switching format A literal quality={80} reinterpreted for a different codec Use the named presets (mid, high) so the per-format mapping follows

Warning: <Image> and <Picture> optimise local images at build time only — there is no runtime resize for a statically-built Astro site. If your images are user-generated or arrive after deploy, you need either an SSR adapter with an image endpoint, a passthrough service that defers to a CDN, or an external image service. Do not expect a static astro build to resize an image that did not exist at build time.

Debugging & validation

Inspect the emitted markup and files

# After `astro build`, confirm derivatives landed in dist/_astro with hashes.
find dist/_astro -type f \( -name '*.avif' -o -name '*.webp' \) -printf '%f  %s bytes\n' | sort

# Extract the generated <img>/<picture> from a built page to confirm the
# srcset, intrinsic width/height, and format order are what you configured.
grep -oE '<(picture|source|img)[^>]*>' dist/index.html | head -n 20

Confirm remote authorisation is working

# A remote image that was optimised will have a /_astro/ URL in the markup.
# If the original remote URL appears verbatim, the host was NOT authorised
# and the image was passed through unoptimised — add it to image.domains.
grep -oE 'src="[^"]*"' dist/index.html | grep -E 'unsplash|_astro'

Measure the encode cost before it becomes a CI problem

# Count the derivatives and their total weight. A gallery page that emits
# several hundred files here is telling you the widths × formats matrix is
# too wide, not that Sharp is slow.
find dist/_astro -type f \( -name '*.avif' -o -name '*.webp' \) | wc -l
du -sh dist/_astro

# Time a cold build with the asset cache cleared, which is what CI does on
# every fresh runner. Compare against a warm rebuild to see how much of your
# build time is image encoding versus everything else.
rm -rf node_modules/.astro dist && time npx astro build

Astro caches transform results between builds in node_modules/.astro, so a warm local rebuild can be an order of magnitude faster than the CI run and hide the real cost. If the two numbers diverge sharply, persist that directory in your CI cache before you start cutting widths.

Confirm the SSR endpoint is actually caching

# In server mode the markup contains /_image?… URLs. Request one twice and
# compare: the second response should be served from the CDN or the adapter's
# cache rather than re-running Sharp.
curl -sI 'http://localhost:4321/_image?href=%2F_astro%2Fhero.Ab12Cd.jpg&w=1200&f=avif&q=60' \
  | grep -iE 'content-type|cache-control|age'

A content-type: image/avif with a long max-age and a rising age on repeat requests is the healthy signal. A content-type matching the source format means validateOptions() rejected the f parameter and the endpoint fell back to passthrough — usually because the format was not one the configured service supports.

Tradeoff: because Astro bakes width/height into every <Image>, it eliminates the Cumulative Layout Shift class of bug that plagues hand-written <img> tags — but it also means a wrong intrinsic ratio (e.g. overriding width without height) produces a distorted image rather than a shifted layout. When overriding dimensions, always set both, and keep the aspect ratio matching the source. Pair this with the CDN caching rules in best practices for setting max-age on CDN media assets so the hashed _astro files are served immutable.