Skip to content
AstroCraft Docs
On this theme

Images & Assets

Grafio runs every image through astro:assets. Sources live in src/, get transformed and hashed into the build, and expose ImageMetadata so <Image> can emit real intrinsic dimensions — which is what keeps cumulative layout shift at zero.

src/assets versus public

public/ holds only what an outside consumer dereferences by literal URL. In this theme that is exactly three files:

public/
├── favicon.ico
├── favicon.svg
└── og.jpg          1200 × 630

og.jpg has to survive rebuilds because links already shared point at that exact path.

Everything else goes in src/. Images there are transformed, hashed, bundled, and fail the build when a path breaks. Images in public/ get none of that: Astro cannot analyse them, so width and height become hand-maintained props, and responsive images are unavailable entirely. The image() helper in a collection schema only accepts paths inside src/.

The ownership layout

Assets in src/ are filed by who owns them, not by what they are.

src/data/work/<slug>/ holds art belonging to exactly one project — its thumbnail and its chapter images. Referenced from frontmatter as ./name.jpg, which removes the ../../../ climb and means deleting a project takes its art with it.

Keep the slug prefix on those filenames. Flattening to thumbnail.jpg across six projects emits six thumbnail.<hash>.jpg files into dist/_astro/ that only the hash distinguishes; the redundancy buys legible build output.

src/assets/images/home/ holds the sets the home page owns: four process- photos, four service- tiles, three avatar- portraits. Sets stay whole even when a member has a second consumer — two of them double as blog heroes, and three stand in as chapter images for work entries with no art of their own. Splitting a set across two folders costs more legibility than the cross-import does.

src/assets/images/shared/ holds images with no single owner. Currently one file, serving six consumers across four pages.

src/assets/logos/ holds the fourteen client logo SVGs.

A consequence of the rule: there is no about/ or contact/ folder, because neither page owns an image — both draw from shared/.

The sizes convention

ProjectCard is the only component using responsive images so far, with layout="constrained" so the source’s own dimensions are the ceiling. Astro never upscales.

sizes is the caller’s prop, not the card’s. How wide a card renders is decided by the grid that places it, so each caller states its own value beside the grid class it mirrors:

// Sections/Home/Work.astro — two-column grid
const CARD_SIZES = "(min-width: 1440px) 664px, (min-width: 768px) 50vw, 100vw";

// Sections/Work/Projects.astro — single-column rows with a 648fr/405fr split
const FRAME_SIZES = "(min-width: 1440px) 780px, (min-width: 768px) 62vw, 100vw";

Those numbers are derived, not guessed: .page-frame caps at 1440px with 40px gutters, giving 1360px inside; the home grid’s two columns with a 32px gap give (1360 − 32) / 2 = 664.

An earlier version kept both strings inside the card, where one of them encoded a grid the card cannot see — change that grid and the value goes stale with nothing to catch it.

Round up when in doubt. Omitting sizes entirely is safe: Astro emits (min-width: 960px) 960px, 100vw, which over-states and only ever costs a candidate step in the srcset. A stale, under-stating sizes makes the browser fetch too few pixels and the frame renders soft, silently. The failure is asymmetric, so bias toward too large.

Measured across the home page’s six thumbnails, tuned values against Astro’s automatic ones: identical on phones at both pixel ratios, 110 KB down to 88 KB on a tablet, 110 KB down to 78 KB on desktop. It buys nothing on phones, where 100vw is already the right answer. The win is tablet and desktop.

The format ladder

<Picture
  src={thumbnail}
  alt=""
  width={960}
  height={640}
  layout="constrained"
  sizes={sizes}
  formats={["avif", "webp"]}
  loading="lazy"
/>

List both formats. formats={["avif"]} alone looks right and silently demotes every non-AVIF browser to the original JPEG — worse than the WebP it would otherwise have received.

AVIF measures around 44% smaller than WebP on these sources. The cost is build time, and it is steep: AVIF encodes roughly 29 times slower than WebP. Eight images at 960 wide took 12.2 seconds versus 0.4, which took one component’s cold build from 9.7 to 23.5 seconds.

Astro caches transformed images between builds, so this lands on cold CI rather than local iteration. But it scales with images multiplied by srcset widths — budget for it before rolling <Picture> out to the fourteen remaining <Image> call sites.

One config note

image: { responsiveStyles: true } is deliberately not set, and there is a comment in astro.config.mjs standing in its place.

Astro recommends it as a default, but it emits :where([data-astro-image]) { height: auto } into an @layer astro.images, which lands squarely on the height: 100% that DistortImage needs to fill a caller’s aspect box. Zero specificity does not settle it — layer order does, and astro.images is not in the layer list global.css declares, so where it sorts comes down to stylesheet load order.

Nothing needs it today: srcset and sizes are HTML attributes that work without it, and every responsive image on the site sits inside a DistortImage frame that already sizes it. If you add it, test the frames first.

A related gotcha: DistortImage targets its slotted image with a descendant selector, [&_img], not a child selector. <Picture> puts a <picture> element between the figure and the image, and a child selector would silently drop size-full and object-cover.

Netlify and sharp

@astrojs/netlify installs its own image service and marks sharp external, so images are never processed locally at all. They ship as source files into _astro/ and Netlify’s Image CDN transforms them per request. The adapter proxies the same endpoint in development, so /_image does not run there either.

That is why sharp is not a dependency here. On a host without an equivalent service, Astro falls back to its own image service, which carries sharp as an optional dependency — usually that just works, and pnpm add sharp is the fix if your package manager does not pull it in. Note that pnpm-workspace.yaml sets allowBuilds: { sharp: false }, which you may need to flip.

What to replace, and at what size

Astro does not upscale. Every <Image> in the theme requests an explicit width, and a source narrower than the request emits the smaller file under a larger width attribute — wrong intrinsic dimensions in the HTML, and visible layout shift.

So match or exceed the shipped sizes:

  • public/og.jpg — 1200 × 630. The current file is a labelled placeholder at the right dimensions, and it doubles as the JSON-LD organisation logo.
  • public/favicon.svg and public/favicon.ico — hard-linked in BaseHead, so replacing the files in place needs no code change.
  • Work thumbnails and chapter images — 960 × 640.
  • Service tiles — 744 × 744.
  • The about and contact workspace photo — 740 × 740.
  • Blog hero images — at least 800 wide (requested at 800×450 in the post hero, 800×500 in the cards).
  • Author avatars — at least 74 × 74, square.
  • Testimonial avatars — 160 × 160.

If you bring wider originals, raise the widths at the call sites to match. The current numbers were chosen against the smallest bundled source, and there are comments in ArticleCard.astro and Sections/Post/Hero.astro saying exactly that.

The rules that apply everywhere

Always set alt. Decorative images take alt="", which is a real answer, not an omission.

Always ship intrinsic width and height. <Image> does this from ImageMetadata when you pass them; it is what prevents layout shift.

Do not lazy-load above-the-fold media. Every route’s hero image uses loading="eager". Everything below uses loading="lazy".

Preload the largest contentful paint if it is an image. The variable fonts are already preloaded in BaseHead; a hero image can be preloaded the same way.

NEXT STEPComponents Reference