# Static assets

**A public file lives in `apps/web/site/assets/`, and a page names it with `asset()`.** One call
returns a content-hashed URL, served `public, max-age=31536000, immutable` by `x dev` and the
container alike, and copied into the static export under the same name. `As of 2026-09-25` (22.3.0).

```tsx
import { asset } from '@ultimat3/render';
import { Image } from '@ultimat3/ui';

<Image
  src={asset('assets/hero-1280.jpg')}
  alt=""
  width={1280}
  height={720}
  sizes="(max-width: 700px) 100vw, 1280px"
  priority
  sources={{
    avif: [
      { src: asset('assets/hero-640.avif'), width: 640 },
      { src: asset('assets/hero-1280.avif'), width: 1280 },
    ],
    webp: [
      { src: asset('assets/hero-640.webp'), width: 640 },
      { src: asset('assets/hero-1280.webp'), width: 1280 },
    ],
  }}
/>
```

`asset('assets/hero-640.avif')` → `/assets/hero-640.3f2a1b9c.avif`.

## The rules

| Rule | Enforced by |
|---|---|
| the path starts with `assets/` and ends in a served extension | the type: `AssetPath` is `` `assets/${string}.${AssetExtension}` ``, so `asset('hero.avif')` and `asset('assets/x.txt')` are TS2345 |
| the file exists | `X_ASSET_MISSING`, thrown while the page renders, so `x build --target static` fails on it and `x dev` shows it |
| no `..`, no empty segment, no backslash | `assetPathProblem()`, the same check in the page, the table and the route |
| only the hashed URL is served | `/assets/hero.avif` is 404; so is a hash that no longer matches the bytes (a document from an earlier build) |
| the URL changes when the bytes change | the hash is xxHash32 of the file, re-read when its mtime or size changes, with no watcher and no restart |
| an earlier build's URL survives a deploy | a static host keeps the old hashed file because nothing deletes it. The container answers it only with `assets.retain` in `app.config.ts`, which reads it from a shared disk on a miss ([Deployment](/deployment#hashed-assets-across-a-deploy--assetsretain)); without it, the stale hash is the 404 above |

Call `asset()` in a page or a server component, never in an island. A browser has no asset table, so
there it throws `X_ASSET_MISSING`: pass the URL to the island as a prop instead.

## From a stylesheet

Sass has the same function, over the same table: `asset('assets/…')` inside `url()` compiles to the
hashed URL, in `x dev`, the container and `x build --target static` alike.

```scss
@font-face {
  font-family: Inter;
  src: url(asset('assets/fonts/inter-var.woff2')) format('woff2');
  font-display: swap;
}

.hero { background-image: url(asset('assets/hero-1280.avif')); }
```

→ `src: url("/assets/fonts/inter-var.9c1d2e3f.woff2") format("woff2")`. A missing file or a path
outside `assets/` is `X_ASSET_MISSING` at compile time, naming the path — not a Sass stack. The Sass
disk cache re-checks every asset a cached sheet named, so a replaced font is never served under its
old hash. Sass has no table without an app loaded, so a unit test that compiles such a sheet
installs one with `setAssetResolver`, as below.

## Served types

| Extension | `content-type` |
|---|---|
| `avif` | `image/avif` |
| `webp` | `image/webp` |
| `png` | `image/png` |
| `jpg`, `jpeg` | `image/jpeg` |
| `gif` | `image/gif` |
| `svg` | `image/svg+xml` |
| `ico` | `image/x-icon` |
| `woff2` | `font/woff2` |
| `mp4` | `video/mp4` |
| `webm` | `video/webm` |
| `vtt` | `text/vtt; charset=utf-8` |

Any other file under `assets/` (a `.psd`, a `README.md`) is not served and not copied.

**Byte ranges are answered.** `Range: bytes=…` gets a `206` with `content-range`, and an
unsatisfiable range gets a `416`. Safari will not play a `<video>` without this.

## Where it happens

| Question | Answer |
|---|---|
| the table | `packages/cli/src/site-assets.ts`: one table per app root, installed into `asset()` by `loadApp` |
| the route | `packages/cli/src/site-asset-routes.ts`: `GET /assets/*file`, mounted by `assetRoutes()`, which both `x dev` and the container compose |
| the export | `writeSiteAssets(root, out)` copies every asset to `out/assets/…/<name>.<hash>.<ext>` |
| the helper | `packages/render/src/asset.ts`: `asset()`, `setAssetResolver()`, `assetPathProblem()` |

`favicon.ico` stays at `apps/web/site/favicon.ico` and the install icon at `apps/web/site/icon.png`.
Browsers ask for those at fixed URLs, so they are not hashed.

## `<Image>`

| Prop | Meaning |
|---|---|
| `src` | the fallback rendition every browser decodes |
| `alt` | required. `""` for a decorative image, on purpose |
| `width` + `height`, **or** `aspectRatio` (`'16 / 9'`) | required. A box is always reserved: neither, one dimension alone, or both forms together is `X_UI_INVALID_VALUE` |
| `variants` | widths (or densities) of `src`'s own encoding, rendered as the `<img>`'s `srcset` |
| `sources` | `{ avif?, webp? }`, each a width list. Renders a `<picture>` with AVIF first, then WebP, then the `<img>` |
| `sizes` | applies to every set |
| `priority` | the LCP image: `loading="eager"` + `fetchpriority="high"`. At most one per route. Never lazy |

Everything else is `loading="lazy"`, `fetchpriority="auto"` and `decoding="async"`.

## Responsive images in a static export

`responsiveImage()` from `@ultimat3/seo` mints `?w=&f=` URLs, which only `/media` answers — a dumb
file server ignores the query and hands back the full original under `<source type="image/webp">`.
So under `x build --target static` every candidate is a FILE instead: `/_img/<source hash>-<width>.<ext>`,
written through core's pipeline after the documents render, one per width in every offered format.
`As of 2026-11`.

| Rule | Detail |
|---|---|
| the `src` | a file the export holds — an `asset('assets/hero.png')` URL. Anything else is `X_IMAGE_SOURCE_UNREADABLE` at build; an absolute `https://` URL keeps its query URL for a CDN that answers one |
| the widths | capped at the source's own (`usableWidths`), never an upscale |
| the formats | `webp` and the source's encoding. `avif` at a static build is `X_IMAGE_UNSUPPORTED`, a build error, never a dead `<source>` |
| what is written | only the candidates an EXPORTED document names: a page rendered just to be weighed writes nothing |
| a served page | keeps the `?w=&f=` URLs, which `/assets` answers itself — [below](#responsive-images-on-a-served-page). The seam is installed by the export alone |

The name is the source's content hash, so a changed image is a new URL and every file can be served
`immutable`. `examples/dummy`'s landing page (`apps/web/site/page.tsx`) is the worked example.

## Responsive images on a served page

`x dev` and the container answer the same candidates on `/assets/<name>.<hash>.<ext>?w=&f=`: the
variant is resized and re-encoded through the pipeline `/media` uses (core's, or the
`ImageTransformDriver` the boot was handed), with its own `content-type` and
`public, max-age=31536000, immutable`. `As of 2026-11`.

| Rule | Detail |
|---|---|
| the widths | exactly the `srcset`'s: `usableWidths(intrinsic, DEFAULT_WIDTHS)` — never an upscale |
| the formats | `DEFAULT_FORMATS` (`webp`), or none for the source's own encoding |
| anything else | `?w=321`, `?f=avif`, `?f=jpeg`, any `?q=`, a `?w=` on an SVG: `404 X_ASSET_VARIANT_UNDECLARED`, before a pixel is decoded. A public URL is never a resize service; an upload under `/media` answers any width |
| a custom `widths` | not served for a site asset; drop the option, or store the image and use `/media` |
| the cache | one decode per variant per content hash, in memory, least recently used out past 64 MiB; a burst of first visitors is one decode |
| a stale hash | the plain route's answer, query or not |

## In a unit test

A test that renders a page calling `asset()` without loading the app installs a table itself:

```ts
import { setAssetResolver } from '@ultimat3/render';

beforeAll(() => setAssetResolver((path) => `/${path}`));
afterAll(() => setAssetResolver(undefined));
```

## Screenshots

`x shot <route> --locale en` photographs `/en/<route>` with `Accept-Language: en`. With no
`--locale`, `Accept-Language` is still pinned to the app's default locale. `x shot --matrix`
photographs every site route in every locale, in light and dark, at 390 and 1440 px, into
`.x/shot/matrix/`, with an `index.html` contact sheet. See [CLI reference](/cli-reference#x-shot).
