Skip to content
scroll to zoom · drag to pan

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.


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

// 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.

  • Omit quality unless you have a hard byte budget. 75 for grids and 80 for 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 accepts avif, 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.

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.


The first version of this recipe was written by reading the source. Executing it found two errors in it:

  1. setCdnBase imported from the wrong package — it failed on the first line. Anyone following the instructions would have bounced immediately.
  2. width=2560 returns 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.