Images & static assets
The build-time image pipeline — ?image imports become srcSet variants (AVIF/WebP + fallback) with inferred dimensions and a blur placeholder; the <Image> primitive, the CDN loader seam, and the sharp setup.
<Image> is the responsive image primitive; the ?image import suffix is the
build-time pipeline behind it. Together they replace next/image: variants and
placeholders are produced at build time for static assets, on demand in dev.
The pipeline: ?image imports
import { Image } from '@voltro/web'
import hero from '../assets/hero.jpg?image'
export default function Page(): React.ReactElement {
return <Image src={hero} alt="Team photo" priority />
}The ?image suffix turns the import into an optimized asset object instead of
a URL string: the build encodes every ladder width up to the intrinsic width
(640 … 3840, capped) as AVIF + WebP plus a same-family fallback (jpeg/png),
hashes the variants into dist/assets/, reads the intrinsic width/height,
and inlines a 16px blur placeholder as a data URI. <Image> renders it as a
<picture> with one <source> per modern format; dimensions and blur are
inferred — the CLS-required width/height props stop being hand-written for
imported assets, and placeholder="blur" is the default (opt out with
placeholder="empty").
The suffix is an explicit opt-in on purpose: a bare image import keeps
Vite's plain hashed-URL semantics, so existing <img src={imported}> and CSS
references are untouched.
Add the ambient type once per app (src/voltro-image.d.ts):
declare module '*?image' {
const asset: {
readonly src: string
readonly width: number
readonly height: number
readonly blurDataURL: string
readonly srcSet: string
readonly sources: ReadonlyArray<{ readonly type: string; readonly srcSet: string }>
}
export default asset
}Dev vs build vs start
voltro buildencodes variants intodist/assets/under content hashes. The transforms run through a persistent cache (.framework/image-cache/), so the second build re-encodes nothing — 500 posts × 8 widths × 2 formats is a one-time cost.voltro devserves transforms on demand from/_voltro/image/<assetId>(same cache). The endpoint answers ONLY for assets registered by an actual?imageimport — a free path parameter would be a dev file-read surface.voltro startserves build artifacts only. There is deliberately no production transform endpoint — no transform-DoS surface. This is a documented dev/prod divergence.
Configuration
// app.config.ts (web)
export default {
type: 'web' as const,
name: 'MyApp',
images: {
formats: ['avif', 'webp'], // modern formats, in <source> order (default)
quality: 75, // encode quality for every variant (default)
},
}sharp — the native encoder
The pipeline runs on sharp, shipped as an
optional dependency of @voltro/cli — auto-available in every project,
nothing to install. sharp ≥0.33 ships prebuilt binaries as @img/* platform
packages with no install script, so pnpm 10's build-script approval gate does
not apply.
If your installer omits optional dependencies, the platform prebuilds are
dropped: sharp resolves but throws on load. The pipeline then serves original
images with ONE loud warning naming the fix (pnpm add -D sharp, or reinstall
without omitting optional deps), and voltro doctor distinguishes
"not installed" from "installed but binary missing". Never a silent
passthrough.
Limits (and the answer for each)
- Dynamic
src— a URL fromloaderDataor CMS frontmatter cannot be seen at build time. Use the loader seam:<Image src={url} loader={cdn}>against your image CDN or the storage plugin's public-serve endpoint (which resizes on the fly). Thequalityprop flows into the loader for exactly this path; for?imageassets quality is baked at build time fromimages.quality. - Remote images — same: loader seam, not the build pipeline.
- Markdown-content images (a blog's relative references) — copied into
dist/assets/content-media/<hash>.<ext>by the content pipeline and thesrcrewritten to that URL. A relative source resolves against the markdown file that references it, and one that does not exist FAILS the build naming the path — a page that renders while its image 404s is the outcome this replaces. Absolute (/…) and remote sources are left untouched. Build-time TRANSFORMATION (resize / format) stays a named non-goal here: a markdown reference carries no width and nosizesto derive one from.
<Image> without the pipeline
Everything from before still holds for plain string src: lazy loading +
async decode by default, priority for the LCP image, required
width/height (or fill) for CLS, sizes, and the pluggable
ImageLoader/ImageConfigProvider seam. See the reference for the full prop
table.