I have written two posts about generating link preview cards with a headless browser on AWS Lambda, one in 2020 and one last year, with a container image, OIDC and a deploy script full of defensive waits. This post is about not doing any of that.
@stati/images 1.0.0 is out today, and the card for this very page was drawn by it, during the build, with no browser in sight.
What core does on its own
First the part you get without installing anything, because it landed in @stati/core 1.28 today as well.
Every Markdown image gets its width and height read from the file, plus loading="lazy" and decoding="async":
<img
src="/images/harbour.jpg"
alt="Harbour at dawn"
width="1600"
height="900"
loading="lazy"
decoding="async"
/>
The browser reserves the space before the image arrives, so the page doesn't jump while you read. Stati reads PNG, JPEG, GIF, WebP and AVIF headers, and turns a photo's EXIF orientation into the right width and height. A page's first image near the top loads eagerly with fetchpriority="high" instead, since it's probably the biggest thing on screen. Images next to a post's Markdown file are copied into /_img/ under a content-hashed name, and stati.image() does the same from a layout, for a cover that comes from front matter.
Add ?raw to a path and Stati leaves the image exactly as written. That is what my pixel art needs, because resampling a 16 by 16 sprite is a crime.
The one thing you owe it is img { max-width: 100%; height: auto; } in your CSS, or a narrowed image keeps its full height.
What the package adds
Two features that need an actual encoder, each on its own switch:
npm install -D @stati/images
// stati.config.ts
import { defineConfig } from "@stati/core";
export default defineConfig({
site: { title: "My site", baseUrl: "https://example.com" },
images: {
responsive: true,
og: true,
},
});
Responsive images
The same Markdown image becomes a <picture> with AVIF and WebP sources at 480, 960 and 1440 pixels wide, and a fallback in the image's own format at the same widths:
<picture>
<source
type="image/avif"
srcset="
/_img/harbour-3f9a2c1b-480.avif 480w,
/_img/harbour-3f9a2c1b-960.avif 960w,
/_img/harbour-3f9a2c1b-1440.avif 1440w
"
sizes="100vw"
/>
<source
type="image/webp"
srcset="/_img/harbour-3f9a2c1b-480.webp 480w, …"
sizes="100vw"
/>
<img
src="/_img/harbour-3f9a2c1b-1440.jpg"
srcset="…"
sizes="100vw"
alt="Harbour at dawn"
width="1600"
height="900"
loading="lazy"
decoding="async"
/>
</picture>
- Images are never enlarged. Widths above the image's own are skipped, and its own width is added when it's smaller than the largest.
- JPEG stays JPEG, PNG stays PNG with its transparency, GIF becomes PNG. PNG is lossless unless you set a quality for it.
- Animated images keep the built-in behaviour. Nobody needs a 48-frame AVIF of a loading spinner.
sizesdefaults to100vw. Tell it how wide your column really is, or a desktop browser downloads an image as wide as the window. This blog's is(min-width: 800px) 720px, calc(100vw - 40px).
The file names carry a hash of the source, the widths, the formats, the quality and the encoder's version, so a generated file can be cached forever, and nothing is encoded twice. The results live in dist/_img/ and are recorded in .stati/images/manifest.json. Keep both in your host's build cache and the next build encodes only the new images. Replacing a photo rebuilds the pages that show it and nothing else, because an image is a dependency of a page the way a partial is.
Encoding is sharp, with the concurrency capped so the encoder doesn't eat the thread pool that the build's own file I/O runs on. By default that's three images at a time.
Link preview cards
With images.og, every page gets an Open Graph image, 1200 by 630, and the og:image, og:image:width, og:image:height, og:image:alt and twitter:image tags to go with it. A page that sets its own image in front matter keeps it, image: false opts a page out, and exclude skips whole sections, like /404.
The card comes from an Eta template. Without one you get the built-in card: site name, section, title, description and the address, in Stati's own typefaces. With a site/og.eta you get yours, and a site/blog/og.eta gives one section its own, nearest wins. Mine shows the catalogue number, the post's three colours as blocks, the title, up to three tags and the colour code. Trimmed down to the bones (pageColors and paletteHex are filters of this blog):
<%
const [c0, c1, c2] = stati.pageColors(stati.page.colors).map((name) => stati.paletteHex(name));
const block = (color) => `display:flex;width:64px;height:64px;margin-right:8px;background-color:${color}`;
%>
<div style="display:flex;flex-direction:column;justify-content:center;width:100%;height:100%;padding:80px;background-color:#0E0E10;color:#F2F2F0;font-family:'Hanken Grotesk'">
<div style="display:flex;align-items:center;margin-bottom:28px">
<div style="display:flex;font-size:64px;font-weight:300;letter-spacing:4px;margin-right:36px"><%= stati.page.catalogue %></div>
<div style="<%= block(c0) %>"></div><div style="<%= block(c1) %>"></div><div style="<%= block(c2) %>"></div>
</div>
<div style="display:flex;font-size:84px;font-weight:800;line-height:1.05"><%= stati.page.title %></div>
</div>
The template is laid out by Satori, turned into SVG and rasterised by sharp. No Chromium, no Puppeteer, no Lambda layers, no /tmp that fills up. It builds on Netlify, Vercel, Cloudflare Pages and GitHub Actions without a system package. Satori has rules, and they are the whole learning curve:
- Inline
styleattributes only. No classes, no<style>, no Tailwind. - Flexbox only, and every element with more than one child needs
display:flex. - Fonts in TTF, OTF or WOFF, not WOFF2. Archivo, Inter and Fira Code are bundled; add your own with
images.og.fonts. Mine is Hanken Grotesk, the same as the site. stati.contentis empty in a card, andstati.oghas the card's width and height. Everything else, site, page, collection, partials and filters, is the same as in a layout.
A card is keyed by a hash of its rendered HTML, so it's redrawn when the title or the site name changes and left alone when you fix a typo in the body. The dev server doesn't draw cards at all unless you set images.dev: 'full', because you don't want to wait for an encoder every time you save. And a production build stops unless site.baseUrl is your public address, since og:image has to be absolute and a card at http://localhost:3000 helps no one.
Why it's a package and not a feature
The concept doc from September 2025 had image optimisation down as a core feature. It ended up a package, for reasons that got clearer the more of it I wrote.
sharp is a native binary, one per platform, pulled in as an optional dependency. Most of the time that's invisible. Some of the time your lockfile was written on a Mac and the Linux build server can't find @img/sharp-linux-x64, and now you're reading a troubleshooting page. Every Stati site would be one npm install away from that, including the ones with no images. Core has nine runtime dependencies today, and I'm trying to make that number go down, not up.
And not every site has photos. A docs site has screenshots at one width. This blog has pixel art that must not be touched. The built-in behaviour, sizes and lazy loading, is free and useful everywhere, so it's in core. Encoding is expensive and useful sometimes, so it's opt-in.
The split is that core keeps every decision and the package only encodes. Which widths to make, how to name the files, what HTML to write, what to cache, when a page depends on an image, which card template a page uses, which pages get a card: all core. The package gets a request and returns bytes. The contract between them is a small ImageProvider interface with an apiVersion, and the provider's version string is part of every hash, so upgrading the encoder changes the URLs and an immutable CDN never serves old bytes under a known name. Core's own tests run against a fake provider that encodes nothing.
If you forget it, you'll know: turn on images.responsive without the package installed and the build stops with the install command. npx create-stati --images installs it from the start, and the prompt defaults to no.
The Lambda can retire now. It had a good run, and two posts.
In the Stati docs: Images, Responsive images and Open Graph images.