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 build encodes variants into dist/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 dev serves transforms on demand from /_voltro/image/<assetId> (same cache). The endpoint answers ONLY for assets registered by an actual ?image import — a free path parameter would be a dev file-read surface.
  • voltro start serves 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 from loaderData or 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). The quality prop flows into the loader for exactly this path; for ?image assets quality is baked at build time from images.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 the src rewritten 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 no sizes to 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.