Images
Stati gives every image in your Markdown its width and height and loads it lazily, with no configuration. The optional @stati/images package adds two features that need an image encoder: responsive AVIF and WebP images, and an Open Graph image for every page.
| Built in | With @stati/images |
|
|---|---|---|
| Markdown images | width, height, loading="lazy", decoding="async" |
<picture> with AVIF and WebP at several widths |
| Images next to a page | Copied to the output | Same, plus responsive variants |
| Link previews | og:image from front matter |
A generated card for every page |
| Configuration | None | images.responsive, images.og |
Built-in behavior
When Stati renders a Markdown image such as , it reads the image’s size from the file and writes:
<img src="/images/harbour.jpg" alt="Harbour at dawn" width="1600" height="900" loading="lazy" decoding="async">
The width and height let the browser reserve space before the image loads, so the page does not shift. Stati reads PNG, JPEG, GIF, WebP and AVIF files, and swaps the width and height of photos that are rotated by their EXIF orientation.
- Where images are found. A path that starts with
/is read from your static directory (public/). A relative path such as./harbour.jpgis read next to the page’s Markdown file, so a post’s images can live in the post’s folder. Stati copies those images to/_img/under a name that changes with the file’s content. - The first image. When a page’s first image sits in its first few blocks, it loads eagerly with
fetchpriority="high", because it is likely the largest thing on screen. Setimages.eagerFirst: falseto load every image lazily. - Images left alone. Remote URLs,
data:URIs, SVG files and other formats are written as authored. Add?rawto a path to opt one image out:. Raw<img>tags in Markdown are not changed. - Missing files. Stati warns and writes the image as authored. The page rebuilds once the file appears.
Your stylesheet should let images shrink with their container without stretching. The starters already include:
img {
max-width: 100%;
height: auto;
}
Note: Without
height: auto, an image with aheightattribute keeps that height whenmax-widthnarrows it. If your site’s CSS lacks the rule, add it, or turn the built-in behavior off withimages: false.
Turning it off
export default defineConfig({
// site: { … },
images: false,
});
Markdown images are then written exactly as authored, and images next to pages are not copied.
Images in templates
Use stati.image() for images that come from layouts and partials, such as a post’s cover image. It returns HTML, so print it with <%~ %>:
<%~ stati.image(stati.page.cover, { alt: stati.page.coverAlt, priority: true }) %>
| Option | Description |
|---|---|
alt |
Alternative text. Use '' for a decorative image; Stati warns when alt is missing. |
priority |
Load eagerly with fetchpriority="high", for the main image above the fold. |
sizes |
The sizes attribute for responsive images. |
class, title |
Added to the <img>. |
Paths resolve like Markdown images: from public/ when they start with /, otherwise next to the page’s Markdown file. An empty src returns an empty string, so templates don’t need a guard.
Responsive images
Install the package, then turn on images.responsive:
npm install --save-dev @stati/images
export default defineConfig({
// site: { … },
images: {
responsive: true,
},
});
Each Markdown image and stati.image() becomes a <picture> with an AVIF and a WebP version at several widths, and a fallback in the image’s own format:
<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="/_img/harbour-3f9a2c1b-480.jpg 480w, …" sizes="100vw"
alt="Harbour at dawn" width="1600" height="900" loading="lazy" decoding="async">
</picture>
images: {
responsive: {
widths: [480, 960, 1440],
formats: ['avif', 'webp'],
quality: { avif: 50, webp: 75, jpeg: 80 },
sizes: '(min-width: 768px) 720px, 100vw',
},
},
- Widths larger than the image itself are skipped, and the image’s own width is added when it is smaller than the largest width. Images are never enlarged.
- The fallback keeps the image’s format: JPEG stays JPEG, PNG stays PNG and keeps its transparency, and GIF becomes PNG. PNG is lossless unless you set
quality.png. sizestells the browser how wide the image is shown. The default,100vw, suits full-width images; give the real width of your content column for smaller downloads on large screens.- Animated images (GIF, WebP, PNG and AVIF) keep the built-in behavior.
File names include a hash of the image and the options, so a changed image or setting gets a new URL and generated files can be cached forever.
Open Graph images
With images.og, every page gets an image for link previews on social networks and chat apps:
export default defineConfig({
site: { title: 'My site', baseUrl: 'https://example.com' },
images: {
og: true,
},
});
Stati adds og:image, og:image:width, og:image:height, og:image:alt and twitter:image to each page, pointing to a 1200 by 630 PNG under /_img/og/. Social networks need absolute URLs, so production builds stop with an error unless site.baseUrl is your site’s public address.
A page keeps the image it sets in front matter, and false gives it none:
---
title: Release 2.0
seo:
openGraph:
image: /images/release-2.png # used as given
---
seo:
openGraph:
image: false # no image, not even a generated one
To skip whole sections, use exclude:
images: {
og: {
exclude: ['/404', '/drafts/**'],
},
},
Designing the card
Without a template, pages use the card built into @stati/images: the site name, the page’s section, its title and description, and its address, in Stati’s own typefaces.
To design your own, add og.eta to site/. For a section with its own card, add one to the section’s folder, such as site/blog/og.eta; each page uses the nearest one. The built-in card, in node_modules/@stati/images/templates/og.eta, is a good starting point.
<div style="display:flex;flex-direction:column;justify-content:flex-end;width:100%;height:100%;padding:72px;background-color:#0b1220;color:#ffffff;font-family:Inter">
<div style="display:flex;font-size:64px;font-weight:500;line-height:1.1"><%= stati.page.title %></div>
<div style="display:flex;margin-top:24px;font-size:28px;opacity:0.7"><%= stati.site.title %></div>
</div>
Card templates receive the same stati object as layouts, with two differences: stati.content is empty, because cards don’t show the page body, and stati.og holds the card’s width and height. Partials and filters work as in layouts.
Cards are drawn by Satori, which supports a subset of CSS:
- Inline
styleattributes only: no classes, no<style>blocks, no Sass. - Flexbox layout. Every element with more than one child needs
display:flex; grid is not supported. - Images from your site work:
<img src="/images/logo.png">is read frompublic/, and a relativesrcfrom the template’s folder.stati.image()works as it does in layouts, so a card can show a post’s cover image; cards always use the original file.
A template Satori can’t draw stops the build in the dev server’s error overlay; in production builds the page is written without a generated image and the error is logged.
Fonts
Archivo (800), Inter (400 and 500) and Fira Code (400) are always available, with Latin, Latin-1 and Latin Extended-A characters. Add fonts with images.og.fonts; paths are relative to the project, and the files must be TTF, OTF or WOFF, because Satori can’t read WOFF2:
images: {
og: {
fonts: [
{ name: 'Young Serif', path: './og/fonts/young-serif-regular.woff', weight: 400 },
],
},
},
Text in other scripts needs a font that covers it.
Using the image in layouts
Stati’s SEO injection and stati.generateSEO() add the image’s tags for you. A layout that writes its own Open Graph tags can read the image as stati.ogImage, with url (absolute), path, width, height and alt:
<% if (stati.ogImage) { %>
<meta property="og:image" content="<%= stati.ogImage.url %>">
<% } %>
Dev server
Encoding images takes time, so by default the dev server skips responsive images and Open Graph cards and shows images with their built-in attributes. Set images.dev: 'full' to generate them in the dev server too. The package must be installed either way.
images.dev |
Responsive images | Open Graph cards |
|---|---|---|
'fast' (default) |
Original image with width and height |
Only images set in front matter |
'full' |
Generated | Generated |
Caching and cleanup
- Each image is encoded once. Later builds reuse the files in
dist/_img/, tracked in.stati/images/manifest.json. - Replacing an image rebuilds only the pages that show it. A card is redrawn when anything it shows changes, such as the page title or the site name, but not when only the page body changes.
stati builddeletes generated images that no page uses any more. It only deletes files it generated, never files frompublic/. Avoid apublic/_img/folder, because it shares its URL with generated images.- Hosts that start each build from a clean checkout encode every image again. To save that time, keep
dist/_imgand.statiin your host’s build cache.
Option reference
| Option | Type | Default | Description |
|---|---|---|---|
images |
false | object |
{} |
false turns off the built-in behavior |
images.eagerFirst |
boolean |
true |
Load the first image near the top of a page eagerly |
images.responsive |
boolean | object |
false |
Responsive variants; needs @stati/images |
images.responsive.widths |
number[] |
[480, 960, 1440] |
Candidate widths in pixels |
images.responsive.formats |
('avif' | 'webp')[] |
['avif', 'webp'] |
Modern formats offered as <source> elements |
images.responsive.quality |
{ avif?, webp?, jpeg?, png? } |
{ avif: 50, webp: 75, jpeg: 80 } |
Encoder quality from 1 to 100; PNG is lossless unless set |
images.responsive.sizes |
string |
'100vw' |
Default sizes attribute |
images.og |
boolean | object |
false |
Open Graph cards; needs @stati/images |
images.og.template |
string |
'og.eta' |
Template file name, looked up from the page’s folder up to site/ |
images.og.width |
number |
1200 |
Card width in pixels |
images.og.height |
number |
630 |
Card height in pixels |
images.og.format |
'png' | 'jpeg' |
'png' |
Card format |
images.og.fonts |
{ name, path, weight?, style? }[] |
[] |
Fonts added to the bundled ones |
images.og.exclude |
string[] |
[] |
URL patterns of pages that get no card |
images.dev |
'fast' | 'full' |
'fast' |
Whether the dev server generates images |
Troubleshooting
images.responsive requires @stati/images
A feature that needs the package is on, but the package is not installed. Run npm install --save-dev @stati/images, or turn the feature off. The same applies to images.og.
@stati/images … is not compatible with @stati/core …
Update both packages to their latest versions.
images.og needs site.baseUrl set to your site's public address
Set site.baseUrl to the address the site is published at, such as https://example.com.
Could not load sharp for linux-x64
The image encoder has a native part for each operating system. If your lockfile was created on another system, run npm install again so it lists every platform’s optional @img/sharp-* package. See the sharp installation guide.
An image shows without variants and the build logged a warning
Encoding failed, for example because the file is damaged. The page shows the original image and is rebuilt on the next build.