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.
sizesandwidthsare data, not classes; they never reach aclassattribute, so a map is fine. Abg-${tone}-500would 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.svgandfavicon.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:
- Drop your files in the matching folder.
- Update the frontmatter references in
src/data/. - Update the direct imports in section components.
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.