A gallery that is fast on a phone and sharp on a desktop
A phone and a 27-inch monitor need very different bytes from the same photo. This page is the recipe: upload once, serve two ladders, with the weights measured so the speed/quality trade is a number and not a feeling.
1 · The imports, which are not all in one package
Section titled “1 · The imports, which are not all in one package”import { NitidaClient, getTransformUrl, getTransformSrcSet, setTenantId,} from "@nitida/sdk";import { setCdnBase, type TransformWidth } from "@nitida/asset-client";⚠️ setCdnBase is not re-exported by @nitida/sdk, while setTenantId
is. Importing both from the SDK fails at module load.
2 · Upload: ask for the ladder, and upload the biggest master you have
Section titled “2 · Upload: ask for the ladder, and upload the biggest master you have”The default is ["original"] and nothing else.
await nt.upload(file, { fileName: "villa-sunset.jpg", presets: ["original", "thumb", "sm", "md", "lg", "xl"],});The source is the ceiling — there is no upscale. Measured: a 2400 px master
asked for width=2560 returns 2400 px. Put widths in your srcSet that
your masters can actually sustain, or the browser picks a candidate that does
not exist at that size.
3 · What each width weighs
Section titled “3 · What each width weighs”Measured on a 2400×1600 master, WebP at quality=75:
| width | 400 | 600 | 800 | 960 | 1080 | 1600 | 1920 |
|---|---|---|---|---|---|---|---|
| KB | 21 | 32 | 56 | 74 | 86 | 123 | 175 |
That table is the trade-off. A phone served at 1080 downloads 86 KB;
served at 1920 it downloads 175 KB — double the weight for pixels the
screen cannot resolve.
| surface | CSS width | DPR | ask for |
|---|---|---|---|
| Mobile · 2-col grid | ~190 px | 2–3 | 400, 480 |
| Mobile · full screen | ~400 px | 2–3 | 800, 960, 1080 |
| Desktop · grid | ~300–400 px | 1–2 | 400, 600, 800 |
| Desktop · lightbox | ~1200–1600 px | 1–2 | 1600, 1920 |
| Full-bleed retina hero | — | 2 | 2560 / 3840 — only if the master reaches it |
4 · One photo, both galleries
Section titled “4 · One photo, both galleries”// No `quality` here — auto-quality probes the MASTER, so the image is// compressed exactly once. Pinning a number turns on the shortcut that reads an// already-compressed variant instead. See the double-pass warning below.<img src={getTransformUrl(asset, { format: "webp", width: 800 })} srcSet={getTransformSrcSet(asset, [400, 800, 1200, 1600], { format: "webp", })} sizes="(max-width: 768px) 100vw, 40vw"/>sizes is what makes the phone pick 800 and the desktop pick 1600 from the
same tag. Leave it out and the browser assumes 100vw — it will fetch the
large candidate for everyone, and the whole ladder buys you nothing.
Quality and format
Section titled “Quality and format”- Omit
qualityunless you have a hard byte budget.75for grids and80for the large view are sensible numbers, but pinning either one is what triggers the second compression pass on a laddered asset — read the warning below first if your assets have the preset ladder. format: "webp". The DSL acceptsavif, but this platform standardises on WebP.- Grid cells:
{ fit: "cover", gravity: "auto" }crops to fill and frames itself — verified to return an exact 400×400. Lightbox:fit: "inside", which never crops.
5 · Widths are a fixed ladder
Section titled “5 · Widths are a fixed ladder”21 of them: 96 · 128 · 160 · 180 · 240 · 256 · 320 · 400 · 480 · 600 · 640 · 800 · 960 · 1080 · 1200 · 1280 · 1440 · 1600 · 1920 · 2560 · 3840.
Anything off it is HTTP 400 at the edge, deliberately — verified with
width=777. Import the TransformWidth type and an off-ladder number becomes a
compile error instead of a production 400.
6 · The first request of each width is cold
Section titled “6 · The first request of each width is cold”A transform is generated on first request and then cached at the edge immutably. If the gallery must be fast for its first visitor too, either ask for those sizes as stored presets at ingest, or warm them with a sweep of requests after upload.
What running it caught
Section titled “What running it caught”The first version of this recipe was written by reading the source. Executing it found two errors in it:
setCdnBaseimported from the wrong package — it failed on the first line. Anyone following the instructions would have bounced immediately.width=2560returns 2400 px when the master is 2400 px wide. The draft recommended 2560 for desktop lightboxes without saying it depends on the source.
Neither is visible from reading the code. That is why the script exists, and why it runs against production rather than a fixture.
A third thing was found later, the same way: the quality: 75 / 80 advice on
this page was written without knowing that pinning a numeric quality is what
enables the stored-variant shortcut — so the recommendation was quietly asking
for a second lossy pass on exactly the assets we tell everyone to upload.
The measurement, on all five documentation photographs and every gallery width,
is the transform benchmark.