---
title: 'Images'
slug: images
ogTitle: 'On-the-fly image transforms'
description: 'On-the-fly image transforms on Bun.Image via named sizes, with encrypted URLs and a stale-while-revalidate disk cache.'
---
## Images
Mochi transforms images on the fly with [`Bun.Image`](https://bun.com/docs/runtime/image) and serves them from an encrypted, stale-while-revalidate disk cache. Declare transforms once as **named sizes** in `Mochi.serve()`, then reference them by name. `` and `getImageUrl()` mint a signed URL only. The fetch, decode, and transform happen lazily in the `/_mochi/image` endpoint on the browser's request, so **SSR never blocks on image work**. Every URL payload is encrypted with a key derived from your `MOCHI_KEY`, so the source URL stays hidden and an attacker cannot request arbitrary sources or transforms.
### Declare sizes
Define transforms under `Mochi.serve({ image: { sizes } })`. Each size is a named recipe:
```ts
await Mochi.serve({
image: {
sizes: {
thumbnail: { width: 200, height: 200, fit: 'inside', format: 'webp', quality: 80 },
avatar: { width: 96, height: 96, fit: 'fill' },
grayscale: { width: 600, modulate: { saturation: 0 }, format: 'jpeg', quality: 85 },
},
},
routes,
});
```
Transforms apply in a fixed order: resize → rotate → flip → flop → modulate → format-encode. Mochi validates sizes at startup. Redefining a size re-renders every URL that uses it, since a config hash is folded into the cache key and `ETag`.
| Size field | Default | Notes |
| -------------------- | ------------------- | ------------------------------------------------------------------ |
| `width` / `height` | — | Target size; height-only derives width by ratio |
| `fit` | `'inside'` | `inside` keeps aspect and fits within W×H; `fill` stretches to W×H |
| `withoutEnlargement` | `false` | Never upscale beyond the source's intrinsic size |
| `rotate` | none | Degrees clockwise |
| `flip` / `flop` | `false` | Mirror vertically / horizontally |
| `modulate` | none | `{ brightness?, saturation?, hue?, lightness? }` (`1` = unchanged) |
| `format` | `defaultFormat` | `webp` \| `jpeg` \| `png` \| `avif` |
| `quality` | `defaultQuality` | 1–100 (ignored for `png`) |
| `autoOrient` | global `autoOrient` | Apply EXIF orientation |
> `Bun.Image` supports `fit: 'inside'` and `fit: 'fill'` only. For an exact square from a non-square source use `fill` (which stretches). Otherwise `inside` keeps the aspect ratio.
### Component
Import `Image` and reference a size by name. It renders one `` with an encrypted `src` and no client JavaScript. The `` `width`/`height` default to the size's declared dimensions.
```svelte
```
Add `placeholder` to render a [ThumbHash](https://evanw.github.io/thumbhash/) blur behind the image. It is set as the `` `background-image` (no client JavaScript), and the loaded image paints over it with a CSS blur-up. The blur is computed in the background on first use, so it appears from the second render onward.
```svelte
```
The ThumbHash blur (left) and the image it resolves to (right).
| Prop | Default | Notes |
| ---------------------- | ----------- | -------------------------------------------------------------------------- |
| `src` | — | http/https URL, or a [local image import](#local-image-imports) (required) |
| `size` | — | Named size; omitted → the full-size original |
| `alt` | `''` | Always set this |
| `placeholder` | `false` | Background-warmed ThumbHash blur-up; pure SSR, no client JavaScript |
| `width` / `height` | size's dims | `` attribute override |
| `loading` / `decoding` | lazy/async | Passed through to `` |
A bare `` with no `size` serves the full-size original. An unknown size name degrades to the original and logs a one-time server warning.
`` works inside `mochi:hydrate*` islands at any depth — it detects the hydrating subtree with [`isHydratable()`](/docs/selective-hydration/#ishydratable). Minting needs the server secret, so inside an island the minted URL is serialized into the page via Svelte's `hydratable` and reused during hydration. If a client-side re-render changes the image props, there is no snapshot to reuse and the `` degrades to the raw `src` URL.
Hydrated-island props ship in plain text in the page HTML, so a `src` you pass into a `mochi:hydrate` island is visible to the client, even though the minted image URL stays encrypted. If your origin must stay secret, keep `` in server-rendered markup or a server island (`mochi:defer`), whose props are encrypted.
### Local image imports
Import a local image Vite-style and get an object with its served URL and intrinsic metadata:
```svelte
```
Supported formats: png, jpg, jpeg, webp, avif, gif. Put SVGs in your `public/` directory and reference them with a plain ``.
Mochi copies the file to a content-hashed URL (`/_mochi/asset/-.`) and serves it from disk with a long-lived immutable cache in production. Transforms read the file from disk, so `` and `placeholder` work without an origin round-trip. Emitted copies live under `/assets/` and [relocate with the build](/docs/deployment-options/#relocatable-builds).
The `{ src, width, height, format }` shape is available as the exported `ImportedImage` type. Ambient module types come free through `mochi-framework/ambient`.
A bare `` with no `size` renders the original at its intrinsic dimensions straight from that static URL. It never calls the image endpoint.
Two edge cases. With `image.enabled: false` the `/_mochi/asset/…` route still serves the file, because that route is plain static serving and registers independently of the flag. The transform does not run: `` falls back to the raw static URL, while the `` keeps the size's declared `width`/`height`, so the browser scales the full-size original into that box. And the [`image:url`](/docs/extensions/#imageurl) CDN-rewrite filter runs on minted transform URLs only, so the no-size static URL (`hero.src`) bypasses it. Use a `size` if you need local assets routed through the filter.
### `getImageUrl` — deferred URLs
`getImageUrl(src, size)` returns an encrypted URL. It is synchronous and near-instant. No fetch happens until the browser requests it.
```ts
import { getImageUrl } from 'mochi-framework';
const url = getImageUrl('https://example.com/photo.jpg', 'thumbnail');
// → /_mochi/image/photo-thumbnail.webp?p=
const original = getImageUrl('https://example.com/photo.jpg'); // no size → the original
```
The URL is relative by default. To serve images from a CDN, register the [`image:url`](/docs/extensions/#imageurl) filter.
### `getImageAttrs` — URL + declared dimensions
`getImageAttrs(src, size?)` returns `getImageUrl` plus the size's declared `width`/`height`. Synchronous, server-only.
```ts
import { getImageAttrs } from 'mochi-framework';
const { url, width, height } = getImageAttrs(src, 'thumbnail');
// → { url: '/_mochi/image/…', width: 200, height: 200 }
```
With `fit: 'inside'` (the default), the served image's real dimensions can be smaller than the declared ones. If the aspect ratio matters for layout, add CSS such as `height: auto` so the declared attributes only reserve space.
### `getImage` — inline bytes + metadata
When you need the transformed bytes server-side (OG images, inlining, a dimension probe), `getImage(src, size)` runs the size inline and returns bytes plus metadata. It shares the same disk cache. Prefer `getImageUrl` for anything that ends up in an ``.
```ts
import { getImage } from 'mochi-framework';
const { bytes, contentType, width, height, format } = await getImage(src, 'thumbnail');
const original = await getImage(src); // no size → the cached original bytes
```
### Placeholder APIs
The ThumbHash blur is also available directly. All three are server-only and share the image cache:
```ts
import { getImagePlaceholder, imagePlaceholder, warmImagePlaceholder } from 'mochi-framework';
const blur = await getImagePlaceholder(src); // compute-and-cache, blocking; data: URL or null
const maybeBlur = await imagePlaceholder(src); // non-blocking read; cached blur or null
warmImagePlaceholder(src); // fire-and-forget compute
```
### Caching & TTL
Mochi stores the original's encoded bytes and its stale-while-revalidate timers on disk (`cacheDir`), so the cache survives restarts. There is one TTL — the original's — and variants follow it:
```ts
await Mochi.serve({
image: {
timeToStale: 14_400_000, // serve fresh for 4h
timeToEvict: 86_400_000, // re-fetch source after 1 day
},
routes,
});
```
- **Fresh** (within `timeToStale`): served from disk.
- **Stale** (between `timeToStale` and `timeToEvict`): served immediately, source re-fetched in the background.
- **Expired** (past `timeToEvict`): re-fetched synchronously.
A background janitor reclaims entries past `timeToEvict` every `sweepIntervalMs` (default 1h). Set `sweepIntervalMs: 0` to disable it.
Served images carry an `ETag` and a `Cache-Control` derived from the cache window (`public, max-age=, stale-while-revalidate=`). In development no `Cache-Control` is sent, so edits and `invalidateImage()` calls always show up on the next request.
### Custom cache storage
Mochi backs the image cache with `FileStorage` under `cacheDir` by default. Pass `storage` to swap in a different backend, for example `MemoryStorage` — the only other built-in one; anything else (SQLite, Postgres, Redis, …) means implementing `Storage` yourself. See [Persistence](/docs/persistence/) for how this compares to other Mochi features.
```ts
import { MemoryStorage } from 'mochi-framework';
await Mochi.serve({
image: {
storage: new MemoryStorage({ maxAge: 86_400_000 }), // must be >= timeToEvict
timeToStale: 14_400_000,
timeToEvict: 86_400_000,
sizes: { thumbnail: { width: 200, height: 200 } },
},
routes,
});
```
**In-memory image caching trades disk for RAM.** Every cached original and variant lives in process memory, so cache size adds to your process's memory footprint. The cache is lost on every restart, so the first request after a restart re-fetches and re-transforms.
### Invalidation
```ts
import { invalidateImage } from 'mochi-framework';
await invalidateImage(src); // mark stale: next request serves cached bytes, re-fetches in background
await invalidateImage(src, { hard: true }); // mark expired: next request blocks for a fresh re-fetch
```
`invalidateImage()` operates on the shared original, so it cascades to every variant and the ThumbHash placeholder.
### Configuration
Configure under `Mochi.serve({ image: { … } })`. Every option is optional.
| Option | Default | Notes |
| ---------------------- | ----------------------- | ------------------------------------------------------------------------- |
| `sizes` | `{}` | Named transform recipes |
| `enabled` | `true` | `false` unmounts the endpoint; URL helpers then return the raw source URL |
| `cacheDir` | `./.mochi/image-cache` | Must not be under `publicDir`; ignored when `storage` is set |
| `storage` | `FileStorage(cacheDir)` | Override the cache backend |
| `defaultFormat` | `webp` | Used when a size omits `format` |
| `defaultQuality` | `80` | Used when a size omits `quality` |
| `outputFormats` | all four | Allowed output formats |
| `allowedHosts` | any public host | Exact host or `*.example.com` |
| `blockPrivateNetworks` | `true` | Reject private/loopback/link-local addresses |
| `fetchTimeoutMs` | `10_000` | Upstream fetch timeout |
| `maxResponseBytes` | `20 MB` | Hard source-size cap |
| `maxPixels` | `50_000_000` | Decompression-bomb guard |
| `timeToStale` | `14_400_000` | Cache time-to-stale (ms); variants follow it |
| `timeToEvict` | `86_400_000` | Cache time-to-evict (ms); variants follow it |
| `sweepIntervalMs` | `3_600_000` | Background cache-janitor interval; `0` disables |
| `compressPayload` | `true` | Deflate the encrypted URL payload |
**Encryption is the security boundary.** The payload is encrypted with a key derived from your `MOCHI_KEY`, so only your server can mint URLs and the source URL stays hidden. If you pass a **user-controlled** `src` into `getImageUrl()`/`getImage()`, keep `blockPrivateNetworks` on (the default) and prefer an `allowedHosts` allowlist so a user cannot proxy requests to internal services. Upstream redirects are followed, but every hop is re-validated against those same checks. Cap the hop count with the [`image:maxRedirects`](/docs/extensions/#imagemaxredirects) filter. A full-size original that is SVG (or any non-raster type) is served as a download rather than inline.
See the [Named sizes demo](/demos/image-pipeline/) and the [Image demo](/demos/image/).