Skip to content
scroll to zoom · drag to pan

Upload and render

upload-an-image.ts
/**
* 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.

image-urls.ts
/**
* 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" },
);

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.

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.

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.