Upload and render
Upload
Section titled “Upload”/** * Upload one image, end to end. * * This file is REAL: `bun run check` type-checks it in CI against the same * workspace version of the SDK this site documents. If a signature changes, * the build fails instead of the website quietly lying. */import { NitidaClient } from "@nitida/sdk";
// The `amk_rt_*` runtime key is a WRITE key to a paid platform. It lives on the// server, in a secret — never in a client bundle, never in `NEXT_PUBLIC_*`.const nt = new NitidaClient({ endpoint: process.env.NITIDA_ENDPOINT ?? "https://assets.example.com", apiKey: process.env.NITIDA_RUNTIME_KEY, tenantCode: "demo", // Required: variant URLs are tenant-prefixed in base36 (`/a/v/<sha>-lg.webp`). tenantId: Number(process.env.NITIDA_TENANT_ID ?? 1),});
export async function uploadImage(file: File) { const result = await nt.upload(file, { // ⚠️ "original" is non-negotiable at FIRST ingest. A variant you do not ask // for here can never be recovered later: the cleanup job reaps `raw/`, and // `regenerate()` then has nothing left to read from. presets: ["original", "thumb", "md", "lg"], // Browser-only; in Node/Bun it no-ops with a warning and uploads raw bytes. compress: true, });
// `upload` returns ids and the canonical URL — not a full asset. Build the // other URLs from the sha. return { assetId: result.assetId, canonical: result.cdnUrl, display: nt.urlFor({ sha: result.sha256 }, "lg"), };}upload() does the whole loop in one call: compress → sha256 → presign → PUT
straight to storage → register → poll until ready. It returns ids and the
canonical URL, not a full asset — build the other URLs from the sha.
Render
Section titled “Render”/** * Build image URLs without uploading anything. * * `@nitida/asset-client` has no network and no key — it is pure URL * construction. Import it directly when all you do is render. */import { getTransformSrcSet, getTransformUrl, setCdnBase, setTenantId, type TransformWidth,} from "@nitida/asset-client";
// The builders read PROCESS-GLOBAL config. Pin it once where your helpers live,// so every module that imports a builder is configured.setCdnBase(process.env.NEXT_PUBLIC_NITIDA_CDN ?? "https://8ok.uk");setTenantId(Number(process.env.NEXT_PUBLIC_NITIDA_TENANT_ID ?? 1));
// Lock the output format to your project's policy rather than using `auto`.const FORMAT = "webp" as const;
/** * ⚠️ The width MUST be on the unsigned ladder. Any other width is an HTTP 400 * at the edge — that is a deliberate DoS guard, not a bug. Importing * `TransformWidth` turns an off-ladder width into a COMPILE error, which is * where you want to find out. */export const imageUrl = (sha: string, width: TransformWidth) => getTransformUrl({ sha }, { format: FORMAT, width });
export const imageSrcSet = (sha: string, widths: readonly TransformWidth[]) => getTransformSrcSet({ sha }, widths, { format: FORMAT });
/** Fixed-aspect cards and thumbnails want a cover crop. */export const thumbUrl = (sha: string, width: TransformWidth) => getTransformUrl( { sha }, { format: FORMAT, width, fit: "cover", gravity: "auto" }, );The two decisions you cannot undo
Section titled “The two decisions you cannot undo”1. The presets you ask for at the FIRST ingest
Section titled “1. The presets you ask for at the FIRST ingest”Ask for what you need here. A variant you skip is not lost — cleanup keeps
raw/ for as long as the asset row exists (verified on a
2-month-old thumb-only asset), so regenerate({ presets }) can add it later.
What you get meanwhile is an incoherence: getAssetUrl(asset,"md") 404s
while /t/…width=1280/<sha>.webp serves the same image on demand.
The real trap is upstream of that: a backup run once archived 97 files
“successfully” after copying a delivery-oriented presets: ["thumb"] from a
migration example, and the operator concluded the bytes were gone without
checking raw. A preset list copied from an example is a decision you did not
make, and “N uploaded, 0 failed” measures the upload, not the outcome.
If the bytes matter, "original" is not optional.
2. The filename you upload under
Section titled “2. The filename you upload under”The stored extension for original comes from the uploaded filename, not
from the MIME type. For JPEG those disagree: image/jpeg implies jpeg while
every camera writes .jpg. Measured: …-o.jpeg 404, …-o.jpg 200.
If you plan to fetch the original back by URL, HEAD it once and store what
answered rather than deriving it twice in two places.
What you get back
Section titled “What you get back”assetId |
the row id — poll this, bind slots to it |
sha256 |
content address — dedup key, and what every URL builder takes |
cdnUrl |
the canonical URL for the asset |
Two uploads of the same bytes return the same asset. That is a feature, and it is also why a retried upload can hide a bug in the non-deduped path — see For agents.