Images & Fonts

Fast images, self-hosted fonts.

An <Image> helper that emits responsive srcset/sizes markup with explicit dimensions (no layout shift), a build-time transform that resizes and re-encodes into dist/, and self-hosted fonts with @font-face + preload — so Apex matches Next / Nuxt on image and font optimization. 🟡 Experimental

What you get #

Two independent, opt-in optimizations — turn on either or both. Both are gated on a config block: declare nothing and nothing changes.

  • The <Image> helper (@apex-stack/kit) — a pure function that renders a responsive <img>: srcset candidates, a sizes attribute, explicit width/height to reserve layout (no CLS), and loading="lazy" / decoding="async" defaults.
  • The image transform — an image block in apex.config.ts wires vite-imagetools into the client build for the default (apex build) and --server targets, turning the ?w=…&format=… candidates into hashed, optimized variants under dist/. (Transforming per-page image imports under --islands is a known follow-up — #57; fonts work in all modes.)
  • Self-hosted fonts — a fonts block copies declared font files into dist/fonts/, emits the matching @font-face rules, and injects <link rel="preload" as="font" crossorigin> into the page shell.

Nothing is forced: the <Image> helper works on its own (plain URLs still render), and the transform / fonts steps only run when their config block is present — keeping the core dependency-light for projects that don't need them.

Quickstart #

Render a responsive image from a template or loader, and (optionally) turn on the build transform + fonts.

// anywhere you build HTML — a loader, a template helper, the client
import { image } from '@apex-stack/kit'

image({ src: '/hero.png', width: 800, height: 600, alt: 'Hero', format: 'webp' })
// → <img src="/hero.png" width="800" height="600" alt="Hero"
//        loading="lazy" decoding="async"
//        srcset="/hero.png?w=800&format=webp 800w, /hero.png?w=1600&format=webp 1600w"
//        sizes="100vw" />
// apex.config.ts — opt in to the build transform + self-hosted fonts
export default defineConfig({
  image: { formats: ['webp'], sizes: [640, 1200], quality: 70 },
  fonts: {
    families: [{ family: 'Inter', src: 'fonts/Inter.woff2', weight: 400 }],
  },
})

The <Image> helper #

image(opts) returns an HTML string; imageAttrs(opts) returns the same as a structured attribute map if you'd rather spread it into your own renderer. Both are pure and browser-safe — no DOM, no Node, no imports.

OptionRequiredDefaultWhat it does
srcYesThe image URL / import path. Query candidates are appended to build srcset.
width / heightYesIntrinsic dimensions, emitted as attributes to reserve layout and avoid CLS.
altNo""Alt text (always emitted; empty = decorative).
widthsNo[width, width*2]Candidate widths for srcset (retina by default). Pass [] to omit srcset.
sizesNo100vwThe sizes attribute (only when a srcset is emitted).
formatNoTarget format appended to each candidate (?format=webp) for the transform.
loadingNolazyeager for above-the-fold hero images.
decodingNoasyncDecoding hint.
fetchpriorityNohigh for the LCP image.

An above-the-fold hero, told to load eagerly at high priority with explicit candidates:

image({
  src: '/lcp.jpg', width: 1200, height: 630, alt: 'Cover',
  widths: [640, 1200], sizes: '(max-width: 640px) 100vw, 1200px',
  loading: 'eager', fetchpriority: 'high',
})

The image config block #

Add an image block and apex build wires vite-imagetools into the client bundle. Imports carrying transform queries — the ones the helper's srcset emits, or ones you write by hand — are resized and re-encoded into hashed variants in dist/. The block's mere presence turns the transform on; every field is optional.

Build-target note

The transform runs in the default apex build (prerender + hydrate) and apex build --server builds. Transforming per-page image imports under apex build --islands (SSG) is a known follow-up (#57) — self-hosted fonts already work in every mode.

export default defineConfig({
  image: {
    formats: ['avif', 'webp'],  // optional — output formats (defaults to source format)
    sizes: [640, 828, 1200],     // optional — responsive srcset widths the helper mirrors
    quality: 70,                 // optional — encode quality (1–100) for lossy formats
  },
})
FieldRequiredDefaultWhat it does
formatsNosource formatOutput formats applied as the transform's default directives.
sizesNoResponsive srcset widths; align these with the widths you pass the helper.
qualityNolib defaultEncode quality (1–100) for lossy output formats.

vite-imagetools is loaded lazily and optionally — like the Tailwind plugin. If it isn't installed, the transform is skipped rather than failing the build. Add it with pnpm add -D vite-imagetools.

Self-hosted fonts #

Declare a fonts block and apex build copies the files into dist/fonts/, emits an @font-face rule per family, and injects a <link rel="preload" as="font" crossorigin> per face into the shell — the browser fetches the font early, from your origin, with no third-party request and no flash of invisible text.

export default defineConfig({
  fonts: {
    display: 'swap',   // optional — font-display for every face (default: swap)
    preload: true,      // optional — emit preload links (default: true)
    families: [
      { family: 'Inter', src: 'fonts/Inter.woff2', weight: 400 },
      { family: 'Inter', src: 'fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
    ],
  },
})

The emitted head fragment, for the first family above:

<style>@font-face{font-family:"Inter";font-style:normal;font-weight:400;
  font-display:swap;src:url("/fonts/Inter.woff2") format("woff2")}</style>
<link rel="preload" as="font" type="font/woff2" href="/fonts/Inter.woff2" crossorigin />
FieldRequiredDefaultWhat it does
families[].familyYesThe CSS font-family name.
families[].srcYesProject-relative path to the font file; copied into dist/fonts/.
families[].weightNo400font-weight ("100 900" for a variable font).
families[].styleNonormalfont-style (italic / oblique).
families[].formatNofrom extensionExplicit format() hint; inferred from the file extension otherwise.
displayNoswapGlobal font-display for every emitted face.
preloadNotrueSet false to emit the @font-face rules without preload hints.

Verify it #

Build and inspect the output the way a browser does.

apex build
apex start         # or any static host
  • View source on a page using image() — the <img> should carry srcset, sizes, explicit width/height, and loading="lazy".
  • With an image block, check dist/ for the hashed, re-encoded variants.
  • With a fonts block, confirm the files landed in dist/fonts/ and DevTools → Network shows the preload firing early with no third-party font request.
  • Run a Lighthouse pass — "properly size images", "serve images in next-gen formats", and "ensure text remains visible during webfont load" should be green.

Scope & status #

The honest edges.

  • 🟡 Experimental — the config surface (image / fonts) may still move.
  • The helper is markup-onlyimage() emits the candidate URLs; the actual resizing happens only when the image block wires the transform in at build.
  • Transform is opt-in + optional — no image block means no plugin; a missing vite-imagetools is skipped, not fatal.
  • No subsetting yet — fonts are copied and preloaded as-is; there's no automatic glyph subsetting.

See Components for how kit renders markup, and Build & deploy for the build modes the transform runs inside.