Skip to content
scroll to zoom · drag to pan

What this is, and when to use it

This is where your product’s media lives. Every image, every video, every PDF — uploaded by a user or shipped by you — stored once, addressed by content, measured per tenant, and delivered from a CDN in whatever shape the page needs.

A modern Cloudinary, built agent-first.

1 · Store everything, once. Content-addressed by SHA-256, so the same bytes are the same asset no matter who uploaded them or what they called the file. Deduplication is not a feature you turn on; it is what an address is.

2 · Get bytes off a device, reliably. Multipart with resume across a reload, progress per file, compression before anything leaves the phone. On mobile, a native background session so the upload survives a locked screen.

3 · Serve whatever shape the page needs. One upload, many outputs: a 256px thumbnail, a 1920px hero, a square crop, a different format — on demand behind a CDN, not pre-generated by you and not stored five times.

4 · Do the video work you do not want to build. Poster, web-safe MP4, an adaptive HLS ladder, and a cheap proxy meant for a model rather than a person.

Static assets belong here too, and that is the point rather than an exception.

A logo in your bundle is a logo you cannot swap without a deploy. Here it is:

  • addressed — one URL, every size, every format, from the same SHA;
  • countable — it shows up in your tenant’s storage and file count;
  • swappable — bind it to a slot and an admin changes the hero without touching code;
  • optional to optimise — ask for ["original"] and it is stored verbatim, byte-for-byte, no re-encode. The superpowers are opt-in, not mandatory.

The last point is worth stating plainly: you can use this as plain storage. A file uploaded with presets: ["original"] comes back exactly as you sent it. Everything else — ladders, transforms, palettes, transcodes — is something you ask for, per asset.

const s = await nt.usage.snapshot();
s.storage.totalBytes // how much this tenant is holding
s.storage.assetCount // how many files
s.today, s.last30Days // what moved
await nt.usage.keys(); // which key did it

Per-tenant storage and file count are first-class, which is what makes this usable as the asset store rather than one of several.

Being honest about the current state, because “collections” means different things to different people:

  • Slots are named bindings — storefront.cr.hero — and they list by prefix, so slots.list({ prefix: "storefront.cr." }) is a namespace. That is the grouping primitive that exists and it is the one most products need.
  • patchMetadata(id, {...}) attaches arbitrary JSON to an asset.
  • A first-class Collection entity does not exist yet. If you need album-like grouping with its own listing and permissions, you build it in your database today, keyed by SHA.

Short list, and none of these are about storage:

  • Destructive editing. Crops, filters and layers that a user manipulates and re-saves. This transforms on read and keeps one canonical original — it is deliberately not an editor.
  • Document intelligence. PDFs and spreadsheets are stored and served intact, but there is no page rendering, no text extraction, no OCR. Store them here; if you need to read inside them, that is a second system.
  • Sub-100 ms first byte on a brand-new derivative. The first request for a shape nobody has asked for yet pays for its own generation. Every request after that is a CDN hit — so this only matters for a cold, one-off shape.
  1. A server-side seam. The upload key cannot live in the browser, so you need one route of your own. About fifteen lines.
  2. Deciding presets at first ingest. A variant you do not ask for on the first upload cannot be added later — see Variants and presets.
  3. Onboarding is a conversation. No self-serve signup yet; you get an endpoint, a tenant id and keys from us. See Getting your credentials.

The SHA is the asset. Everything else — every size, every format, every URL — is a function of it.

Once that lands, the API stops having surprises: upload() gives you a SHA, and every builder takes a SHA and returns a URL.