Images & Assets
Finly optimizes images through astro:assets at build time. Sources live in src/assets/images/, get imported rather than referenced by path, and come out as hashed WebP variants with real intrinsic dimensions.
Seventy-four source files ship — photographs, product screenshots, portraits and per-connector window shots — and twenty-two components go through <Image> or <Picture>.
The basic shape
---
import HeroImage from "@images/hero-finance-review.jpg";
import { Image } from "astro:assets";
---
<Image
src={HeroImage}
alt={hero.imageAlt}
widths={[640, 960, 1280, 1600, 2048]}
sizes="(min-width: 1024px) 50vw, 100vw"
loading="eager"
/>
Importing rather than pointing at a path is what makes the rest work. Astro reads the file at build, so it knows the intrinsic width and height and can emit them on the element — which is what kills cumulative layout shift — and it can generate the whole responsive set.
@images/* is a path alias for src/assets/images/*, so imports stay short from anywhere in the tree.
Responsive widths
Every band that draws a photograph passes an explicit widths array, sized to what that band actually renders. A few from the theme:
widths={[640, 1024, 1536]} <!-- the blog post cover -->
widths={[268, 400, 536]} <!-- the careers life strip's small tiles -->
widths={[1024, 1600, 2000]} <!-- the full-bleed statement band -->
Pair widths with a sizes attribute that describes the layout, or the browser assumes the image is full-viewport-width and downloads more than it needs. Astro emits the srcset; sizes is the part only you know.
Above the fold
The rule is short: do not lazy-load the LCP image. Astro lazy-loads by default, which is right for almost everything and wrong for the one image at the top of the page. Hero images across the theme carry loading="eager", and MaskedImage exposes fetchpriority for the cases where the hint is worth adding too.
The variable font is already preloaded in BaseHead. If a hero image is your LCP rather than the heading, preload it the same way.
Alt text is content
Every band on the site splits its images the same way, and it is worth stating plainly because it explains the shape of the config layer:
- The alt text is content and lives in
src/config/<locale>/, beside the copy it describes. - The file is a static import at the call site, because a path read from config at runtime cannot be optimized by
astro:assets.
Some bands carry two alt strings for one visual, and that is deliberate rather than redundant. The product alternator rows draw a photograph with a product screenshot on top of it, so the type declares imageAlt for the atmosphere and windowAlt for the screenshot — because the screenshot is the actual claim and needs its own description.
The registry that keeps the two halves in step
The split leaves one hazard: two lists that have to stay aligned, with nothing checking it. @js/imageRegistry is the eight lines that close it.
const PORTRAITS = { "Ruth Adeyemi": Ruth, "Bram de Vries": Bram, /* … */ };
const portrait = imageFor(PORTRAITS, person.name, "TeamSection");
export function imageFor(registry, key, source): ImageMetadata {
const image = registry[key];
if (!image) throw new Error(`${source}: no image imported for "${key}"`);
return image;
}
It throws rather than falling back, because the failure it prevents is silent: a config entry with no matching import renders a person-shaped hole on a wall whose own heading says a hundred and eighty. A placeholder would let that survive review; a build that fails names the entry and the component to add the import to.
It is keyed, never positional. Two of the three call sites it replaced indexed by array position and carried a note admitting that reordering the config silently repaints every card with the wrong photograph — a failure with no error and no visible symptom unless you know the people. A key the config already holds costs the same line and cannot do that.
Three bands use it: the about page’s portraits and office cards, and the careers page’s life photographs.
Content collection images
Blog posts and customer stories declare their hero through the schema’s image() helper:
heroImage: image(),
imageAlt: z.string(),
That returns bundled ImageMetadata, not a string — so the entry’s cover is optimized like any other asset, and BaseHead can emit real og:image:width and og:image:height for it. It is required in both schemas, against the usual advice to make images optional, precisely so every post and every story has a correct social card without the page having to branch.
Reference it relatively from the entry folder — heroImage: ./cover.jpg — or point at a shared file under src/assets/images/.
Inline SVG rather than image files
Two sets of vector assets are TypeScript modules rather than files, and both are inline SVG resolved at build:
- The 571-icon registry in
src/components/svg/icons/icons.ts, behind the typed<Icon>component. - The sixty vendor marks and the SSO lockups in
src/components/svg/logos/.
Inlining means the markup lands in the HTML, nothing lands in client JavaScript, and the geometry uses currentColor so a text-* token recolours it and dark mode is free. The trade-off — one module carrying every glyph — is stated in the registry itself; see Icons.
When to use /public instead
public/ holds exactly three files, and each is there for a reason astro:assets cannot serve:
favicon.svgandfavicon.ico— referenced by browsers at fixed paths.og.jpg— the site-wide social image, referenced as an absolute URL byBaseHeadand by the JSON-LDOrganizationlogo. A social crawler needs a stable URL, not a hashed one.
That is the rule: /public is for files that must keep a fixed, unhashed path. Everything else goes through src/assets/.
The shipped og.jpg is a labelled placeholder, and replacing it fixes two things at once — the default social card and the Organization logo in the structured data, which currently points at it with a ponytail: note in BaseHead saying so.
Adding an image
- Put the file in
src/assets/images/(or beside the collection entry it belongs to). - Import it at the call site, using
@images/*for shared assets. - Render with
<Image>or<Picture>, always withalt, and pass awidthsarray sized to what the band renders plus a matchingsizes. - Put the alt string in the page’s config module, not in the component.
- If the band maps a config list onto a set of imports, route the lookup through
imageForrather than indexing by position.
sharp does the optimization and is a devDependency — it runs at build and ships nothing. If images render as alt text in a long-running dev server, sharp has stopped resolving in that process; restart it. A build that renders correctly while dev does not is the tell, and it is a known Astro dev-daemon behaviour rather than a bug in your markup.