Skip to content
AstroCraft Docs
On this theme

Images & Assets

Develi 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.

The one rule that governs everything else: if you want it optimized, it goes in src/assets/ and gets imported. If it must keep its exact filename and bytes, it goes in public/. Only three files are in public/ here — two favicons and the OG placeholder.

Where things live

src/assets/
├── art/          # decorative SVG (the grid overlay)
├── clients/      # client logos, SVG
├── footer/       # the footer wordmark and its mask, SVG
├── images/       # photography
│   ├── team/     # author and team headshots
│   └── work/     # case-study photography
└── logos/        # tech-stack marks, SVG + one PNG

Content images are referenced relatively from frontmatter and validated through Astro’s image() helper, so a missing file fails the build:

heroImage: ../../../assets/images/about-hero-team.jpg
cardImage: ../../../assets/images/work/meridian.jpg

There is also an @images/* path alias pointing at src/assets/images/, for imports from component code.

The <Image> pattern

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

<Image
  src={heroImage}
  alt="Two Develi engineers working side by side at a studio desk"
  widths={[260, 400, 520]}
  sizes="(min-width: 1024px) 22vw, 55vw"
  loading="eager"
/>

Passing a bundled ImageMetadata rather than a string is what gives you intrinsic width and height on the emitted tag, which is what kills cumulative layout shift. It is also what lets BaseHead emit real og:image:width/height for a page’s hero.

widths and sizes together

Neither is useful alone. widths tells Astro which variants to generate; sizes tells the browser which one to pick. Getting them right is the difference between a card loading a 340px image and a 1120px one.

The theme’s clearest example is PostCard, which draws four different box sizes and keeps a lookup table:

const MEDIA = {
  featured: { widths: [560, 840, 1120], sizes: "(min-width: 1024px) 560px, 92vw" },
  grid:     { widths: [340, 510, 680],  sizes: "(min-width: 1024px) 340px, (min-width: 640px) 45vw, 92vw" },
  compact:  { widths: [128, 256],       sizes: "128px" },
  list:     { widths: [192, 384],       sizes: "(min-width: 640px) 192px, 128px" },
} as const;

Each entry is the box that variant actually draws, at 1× and 2× (and 3× for the two larger ones). That is the method: measure the rendered box, then list it at each density you care about.

A lookup keyed on a variant is safe here — and worth understanding, because the theme otherwise forbids it. The “never interpolate a class name” rule is about what the Tailwind compiler can see. sizes and widths are data, not classes; they never reach a class attribute, so a map is fine. A bg-${tone}-500 would not be.

Loading

loading="lazy" is Astro’s default and is right for almost everything. Set loading="eager" only on above-the-fold media.

The theme’s hero pair does exactly that, and the post cards deliberately do not — no post card is ever the LCP element, because the page’s own masthead sits above all of them. Think about which element is actually your LCP rather than eagerly loading everything visible.

If a hero image is your LCP, preload it the way the variable font is preloaded in BaseHead.

Alt text

Always set alt. An empty alt="" is a decision, not an omission, and the theme uses it deliberately:

{/* Decorative: the headline sits beside it and is the card's accessible name, so a described
    image would make a screen reader announce the card twice. */}
<Image src={heroImage} alt="" widths={[...widths]} sizes={sizes} class={image()} />

The test is whether the image carries information the surrounding text does not. A card whose headline is right beside the image needs alt="". A case-study gallery photograph needs a real description — and the work collection’s schema requires one:

gallery:
  - image: ../../../assets/images/work/case-gallery-wide.jpg
    alt: The rebuilt Meridian trading console on a desk of paired monitors

Making alt required in the schema rather than optional is the reason none of the shipped gallery images is missing one.

SVG

SVGs in src/assets/ are imported like any other asset. Two patterns are worth distinguishing:

An external brand mark — a client logo — stays an image. It is somebody else’s mark and its colours are facts about it, not themeable values.

Artwork the theme owns gets inlined so it can take its colour from the theme. That is what ui/logo does: the Develi lockup is one inline SVG whose wordmark is fill-current and whose mark is fill-primary, which is only possible because it is inline rather than an <img>.

The distinction is ownership, not convenience. If you could restyle it, it themes. The 404 illustration in this theme was the standing counter-example — it shipped with a stock palette of raw hex values, so its paper and accent layers did not follow the theme, and nobody noticed because the 404 mounts no theme toggle and is therefore dark-only. It now splits the way any owned artwork should: brand shapes to currentColor, surfaces and shading to fill-card / fill-foreground / fill-muted.

public/

Three files, and each is there for a reason src/assets/ could not serve:

  • favicon.svg and favicon.ico — referenced by fixed paths in <head> and by browsers that guess at /favicon.ico.
  • og.jpg — the default social image, referenced by an absolute URL in meta tags. It must keep a stable, unhashed path so a shared link resolves it.

Replace all three before launch. The shipped og.jpg is a placeholder, and its 1200×630 dimensions are what BaseHead claims for every page without an image of its own — so keep those dimensions when you swap it.

Anything you add to public/ is copied verbatim: no optimization, no hashing, no cache-busting. Use it only when a fixed filename is genuinely required.

Replacing the sample photography

The theme ships photography under src/assets/images/. Replace it with your own:

  1. Drop your files in the matching folder.
  2. Update the frontmatter references in src/data/.
  3. Update the direct imports in section components.
  4. pnpm build — a broken reference fails the build with the file named.

Dimensions do not need to match exactly. The design crops with object-cover and the schemas do not constrain aspect ratio. What matters is that a source is at least as large as the biggest widths entry that will be generated from it, or you will be upscaling.

Compress before committing. Source files live in git forever, and a template repository that buyers clone pays for every oversized asset permanently. Eight full-quality 2× Figma exports totalling about 4.1 MB were recompressed to roughly 0.49 MB (mozjpeg, quality 82) before entering this repo’s history — identical dimensions, and the delivered WebP sizes were unchanged, because Astro re-encodes anyway. The saving is purely in what git carries.

What the build produces

A pnpm build on the shipped content emits around 116 image variants into dist/_astro/, each hashed and served as WebP. Astro caches them between builds, so a rebuild that changes no images reuses the cache and finishes in seconds.

sharp is a devDependency, not a runtime one. The site is fully static, so image work happens entirely at build time and nothing about it reaches your host.

NEXT STEPComponents Reference