Skip to content
AstroCraft Docs
On this theme

Images & Assets

Urengi optimizes images through astro:assets at build time. Sources live in src/assets/, get imported rather than referenced by path, and come out as hashed WebP variants with real intrinsic dimensions.

sharp does the work. It is a runtime dependency in package.json and it is used only at build; the output is plain files.

Where things live

src/assets/
├── images/
│   ├── authors/          4 byline portraits
│   ├── blog/             8 post heroes
│   ├── case-studies/     7 story heroes
│   ├── product/         17 product mocks and panel art
│   ├── team/             8 team portraits
│   └── *.jpg             3 shared — the home hero, a panel texture, a testimonial portrait
├── logos/               35 brand marks, as SVG
├── logo-wordmark.svg    the Urengi wordmark
└── logo-wordmark-lg.svg the oversized footer watermark

public/
├── favicon.svg
├── favicon.ico
└── og.jpg               the default social image — a placeholder

The rule is short. Anything the build should optimize goes in src/assets/ and is imported. Anything that must keep a stable, predictable URL goes in public/ and is referenced by path.

public/ holds exactly three files here, and each is there for the right reason: browsers and crawlers ask for favicons at fixed paths, and og.jpg has to be reachable at a URL that survives a rebuild because it is pasted into link previews and cached by other people’s servers.

The two ways an image arrives

From frontmatter, through the collection schema’s image() helper:

heroImage: "../../../assets/images/blog/soc-2-type-ii.jpg"
heroImageAlt: "A framed certificate standing on a desk in a warm-lit office"

The path is resolved at build and the file must exist, so a typo is a build error rather than a broken <img>. What the component receives is an ImageMetadata object with real width and height — which is also why the post’s hero can be passed straight to BaseLayout as the page image and produce correct og:image:width and og:image:height.

From an import, in a component:

---
import { Image } from "astro:assets";
import heroTeam from "@assets/images/hero-team-review.jpg";
---

<Image src={heroTeam} alt="…" widths={[640, 960, 1280, 1600, 2400]} sizes="…" loading="eager" />

The responsive pattern

Every <Image> in the theme passes widths and sizes together, and the widths are chosen for the box the image actually occupies rather than from a generic ladder.

<!-- the featured blog card: one wide card at 523px, near-full-bleed below lg -->
<Image src={heroImage} alt={heroImageAlt}
       widths={[480, 720, 1080]}
       sizes="(min-width: 1024px) 523px, 92vw" />

<!-- a grid thumbnail in the same component: three across -->
<Image src={heroImage} alt={heroImageAlt}
       widths={[360, 540, 720]}
       sizes="(min-width: 1024px) 325px, (min-width: 640px) 45vw, 92vw" />

Same source image, two different ladders, because the two slots are different sizes. Copying a widths array from a neighbouring component without checking its sizes is how a thumbnail ends up downloading a 1600px file.

loading is explicit on anything below the foldloading="lazy" on team portraits, device frames and product mocks; loading="eager" on the home hero, which is the LCP element on that page. Astro’s default is lazy, so the eager ones are the deliberate exceptions.

Aspect ratio and cropping are the frame’s job, not the image’s: a wrapper owns the aspect, the radius and the clip, and only the picture inside it scales. One detail there is worth knowing before you copy it — those frames use overflow-clip, never overflow-hidden, because a hidden box is a scroll container, and a scroll container would make the view() timeline in ImageReveal track the frame instead of the page, freezing the animation.

Alt text

Alt is required on both collections’ heroes, in the schema, beside the image field. An optional alt is an alt nobody writes.

Two rules the theme follows:

  • Describe the image, do not caption the page. The alt on a post hero says what the picture shows, not what the post argues.
  • Decorative images get alt="", and decorative icons get nothing at all — the <Icon /> primitive is aria-hidden by default and only becomes an image with an accessible name when you pass title.

ESLint runs the Astro JSX accessibility rules, so a missing alt fails pnpm lint.

SVG logos are inlined, not served

The 35 brand marks under src/assets/logos/ do not go through <Image>. They are read as raw source and inlined into the document by logoSvg in @js/logos:

<span set:html={logoSvg("slack")} />

Two reasons. The home page’s logo wall is drawn monochrome, so those paths carry fill="currentColor" and follow the theme into dark mode — which only works if the SVG is part of the document. The integrations orbit keeps its marks’ real brand colours, and inlining them through the same door means one mechanism rather than two.

logoSvg throws if the stem has no file, because a missing logo must fail the build rather than render a silent gap.

It also namespaces every internal id, and that is not cosmetic. Figma names ids in an export by position, so all of these files declare clip0_0_1 and most declare a second clip and a gradient id. Inlined into one document those become duplicate ids, and every url(#clip0_0_1) on the page resolves to whichever logo rendered first — so most marks get clipped by a stranger’s clipPath. It shipped that way for exactly one browser pass, where a gradient-filled mark rendered as an empty white circle. Fixing it in the helper rather than in the files means a re-export from Figma cannot bring the bug back.

The same module also strips unreferenced ids and drops clipping artifacts, which is the other half of what a Figma SVG export tends to carry.

The wordmark is handled the same way, imported with ?raw and inlined, because it is a two-tone mark that has to sit inside a themed pill.

Replacing the demo imagery

  1. Drop your files into the matching folder under src/assets/images/.
  2. Update the frontmatter paths in src/data/, or the imports in the section that draws it.
  3. Delete what you are not using — an unimported file in src/assets/ costs nothing at build, but it is one more thing to audit later.
  4. Replace public/og.jpg with a real 1200×630. It doubles as the JSON-LD Organization logo until you pass a real brand logo, so this one file fixes two things.
  5. Replace public/favicon.svg and public/favicon.ico.

Then read THIRD-PARTY.md. The demo photography’s provenance is unrecorded and the 35 brand logos are their owners’ trademarks — neither is the template licence’s to grant, and the section covering them is marked confirm before production.

When to use /public instead

  • Files that need a fixed URL: favicons, og.jpg, manifest.webmanifest, verification files.
  • Anything referenced from outside the build — an email template, a third-party embed.
  • Very large downloads you do not want the image pipeline to touch.

Everything else belongs in src/assets/. A file in public/ is copied verbatim: no format conversion, no responsive variants, no hash, and no build error if you point at a name that does not exist.

NEXT STEPComponents Reference