Images
The build optimizes images automatically: every eligible
<img>
gets a responsive
srcset
of resized, format-converted variants, plus lazy-loading attributes. It's on by default — you configure it (or turn it off) under the
images
key in
project.json
.
Configuration
All properties are optional; these are the defaults:
{
"images": {
"optimize": true,
"widths": [320, 640, 960, 1280, 1920],
"formats": ["webp", "avif"],
"quality": { "webp": 80, "avif": 65, "jpeg": 80, "png": 80 },
"sizes": "(max-width: 768px) 100vw, 50vw",
"lazyLoad": true,
"service": "build"
}
}
| Property | What it controls |
|---|---|
optimize |
Master switch —
false
disables all image processing |
widths |
Pixel widths for the responsive variants |
formats |
Output formats (
"webp"
,
"avif"
,
"jpeg"
,
"png"
) |
quality |
Per-format compression quality (0–100) |
sizes |
Default CSS
sizes
attribute injected alongside
srcset |
lazyLoad |
Adds
loading="lazy"
and
decoding="async" |
service |
"build"
= Sharp at build time;
"cloudflare"
= transform URLs served by Cloudflare (below) |
remoteDomains |
Https hostnames whose remote images also get transform srcsets —
"cloudflare"
service only |
What the build does
For each eligible image, the pipeline (powered by Sharp ):
Filters widths to those at or below the image's natural width — no upscaling; the original width is always included as a breakpoint.
Generates one variant per width × format and writes it to
dist/images/_optimized/{stem}-{width}-{hash}.{format}(e.g.hero-640-a1b2c3d4.webp).Mutates the
<img>in the compiled HTML: asrcsetlisting the variants in the best configured format (AVIF when configured, otherwise the first format), plussizesfrom config (unless the node already sets one), andloading="lazy"/decoding="async"whenlazyLoadis on. The originalsrcstays as the fallback.
Images embedded in pre-rendered Markdown content go through the same transformation, so a

in a blog post is optimized like any hand-placed
<img>
.
Which images are eligible
Processed:
<img>
nodes with a static, local
src
— a string, not a
expression or a
$ref
— pointing at a raster file (
.jpg
,
.jpeg
,
.png
,
.webp
,
.avif
,
.tiff
) that exists on disk.
Skipped automatically:
External URLs (
http://,https://,//,data:)SVGs and GIFs
Dynamic
srcvalues (template expressions or bindings)Anything carrying a
data-no-optimizeattribute
Where srcs resolve
In pages, layouts, and components, image paths resolve against the
site root
, not the referring file: a
/
-prefixed src like
/images/hero.jpg
resolves into
public/
(so it's
public/images/hero.jpg
on disk, and works verbatim at runtime too), while a relative src like
content/blog/images/hero.jpg
resolves from the project root.
Content entries are the exception — they resolve against themselves, so a collection stays readable in a markdown editor:

A relative reference in an entry is remapped to the collection's own URL —
content/blog/images/diagram.png
becomes
/content/blog/images/diagram.png
— and the build copies the file there. See
Content collections
.
Per-image overrides
Individual images can override the global config through ordinary attributes:
{
"tagName": "img",
"attributes": {
"src": "/images/hero.jpg",
"alt": "Hero image",
"sizes": "(max-width: 640px) 80vw, 40vw",
"loading": "eager"
}
}
sizes— replaces the configured default for this imageloading="eager"— keepsloading="lazy"off an above-the-fold imagedata-no-optimize— skips the pipeline entirely
Caching
Variants are cached in
.cache/images/
(with a
manifest.json
) so unchanged images aren't re-encoded on the next build. The cache key combines the source file's content hash with a hash of the optimization config — changing either the image or the
widths
/
formats
/
quality
settings invalidates the entry, as do missing variant files. The cache survives
dist/
cleanup; add
.cache/
to
.gitignore
, or commit it to speed up CI builds.
The cache is self-pruning: after a fully successful build, entries no build step touched (deleted or replaced source images, superseded configs) are dropped and their variant files deleted, so a persisted cache — and the
dist/images/_optimized/
copy made from it — stays bounded to the images the site actually uses, even when the cache lives forever (for example in a CI cache). Builds that end with errors skip pruning — a page that failed to compile never touched its images, and evicting them would force a pointless re-encode.
The Cloudflare service
Setting
"service": "cloudflare"
replaces the build-time pipeline with pure markup: eligible images get a
srcset
of
/cdn-cgi/image/...
transform URLs (one per configured width,
format=auto
so Cloudflare negotiates AVIF/WebP per browser, quality from
quality.webp
), and no variants are generated at build time. Remote
https
images from hostnames listed in
remoteDomains
get the same treatment.
This requires the site to be served through a Cloudflare zone with Image Transformations enabled (dashboard:
Images > Transformations
). The URLs do not resolve on
*.pages.dev
or
*.workers.dev
preview hosts — previews fall back to the untouched originals.
Related
Build output and adapters — where the variants land in
dist/Media in Studio — browsing and placing images visually
project.json — the full configuration reference