Core concepts
The mental model behind Radon Storage — providers, the storage contract, keys, capabilities, signed URLs, test vs live, and the native() escape hatch.
Radon Storage has a small number of ideas. Once they click, every method and every provider behaves predictably.
Providers
A provider is a place objects live — Amazon S3, Cloudflare R2, Supabase Storage, the local filesystem. You configure the ones you use; Radon lazy-loads each adapter only when it's first touched, so an S3-plus-R2 app never bundles Azure's code.
const storage = new RadonStorage({
providers: {
s3: { bucket: "uploads", region: "us-east-1" }, // options + creds from env
r2: { bucket: "backup", accountId: process.env.CF_ACCOUNT },
},
defaultProvider: "s3",
});Every call uses defaultProvider unless you override it per call:
await storage.upload({ key: "a.png", body: buf }, { provider: "r2" });Each provider reads its credentials from the providers config block first,
then falls back to RADON_<SLUG>_* environment variables. Config always wins;
whatever you omit is pulled from the environment.
Why swapping is a config change
Every provider implements the same interface — upload, download,
getUrl, list, and so on. The shapes you pass in and get back are identical
across providers, so changing your storage backend is a config edit, not a
rewrite.
The storage contract
Under the hood, every adapter implements one TypeScript interface,
StorageProvider. These are the operations it guarantees:
upload(input) => Promise<UploadResult>Store an object from bytes, a stream, text, or a local file path.
download(key) => Promise<Buffer>Read an object's bytes into memory.
delete(key) => Promise<void>Remove an object. A no-op (not an error) if the key doesn't exist.
exists(key) => Promise<boolean>Whether an object exists at key.
getMetadata(key) => Promise<ObjectMetadata>Size, content type, timestamps, and user metadata for one object.
getUrl(key, options) => string | Promise<string>A public or signed URL for an object.
list(prefix, options) => Promise<ListResult>A paginated page of objects (and "folder" prefixes) under a prefix.
copy(sourceKey, destKey) => Promise<UploadResult>Server-side copy, where the provider supports it.
Large-file multipart methods are optional —
an adapter that can't do them omits them, and Radon raises a clear
UnsupportedOperationError instead of failing silently.
Keys
A key is an object's path within the bucket — avatars/ada.png,
reports/2026/q1.pdf. Keys are always bucket-relative: a leading slash is
trimmed for you, so /a/b.png and a/b.png address the same object.
await storage.upload({ key: "/avatars/ada.png", body: buf }); // stored as "avatars/ada.png"Slashes inside a key are preserved and used as "folder" separators by
list() when you pass a delimiter.
Capabilities
Providers differ in what they can do — the local filesystem can't sign URLs; Vercel Blob has no server-side copy. Rather than fail unpredictably, every adapter declares a static capabilities descriptor you can read up front:
const s3 = await storage.provider("s3");
s3.capabilities;
// {
// upload: true, delete: true, publicUrls: true, signedUrls: true,
// presignedUpload: true, list: true, metadata: true, multipart: true, copy: true,
// }Call an operation a provider can't do and you get a typed
UnsupportedOperationError naming the provider and the
operation — never a silent failure. The Providers
page lists every provider's capabilities.
Normalized results
Every upload() returns the same UploadResult shape, whatever the
provider:
keystringThe stored object key.
providerstringWhich provider stored it ("s3", "r2", …).
sizenumber | undefinedBytes stored, when the provider reports it.
etagstring | undefinedThe entity tag (usually an MD5 or opaque hash) the provider assigned.
urlstring | undefinedA public URL, when one is determinable without signing (public bucket / CDN).
Use getUrl(key, { signed: true }) for private objects.
metadataRecord<string, string>Your user metadata, echoed back.
rawunknownThe untouched provider response — reach here for fields Radon doesn't normalize.
getMetadata(), list(), and the rest are normalized the same way. See the
API reference for every return shape.
Public vs. signed URLs
There are two ways to hand out an object:
- A public URL works only for objects in a public bucket or on a CDN. It's a
plain, permanent link.
getUrl(key)returns it. - A signed URL is time-limited and cryptographically signed, granting access
to a private object for a window you choose.
getUrl(key, { signed: true })returns it.
storage.getUrl("report.pdf"); // public URL (bucket must be public)
await storage.getUrl("report.pdf", { signed: true }); // signed GET, default 1 hour
await storage.getUrl("up.bin", { signed: true, method: "PUT" }); // presigned upload URLSigned URLs are covered in full on the Signed URLs page.
getUrl and list use the primary provider only
Even with a failover chain configured, getUrl() and
list() always target the primary (default) provider — they never fail over.
Both are deterministic, per-provider operations: a signed URL is only valid for
the store that minted it.
Test vs. live
mode hints sandbox vs production to the providers that distinguish them. Most
object stores ignore it — a bucket is a bucket — so it's a no-op for the S3
family, but it flips your whole integration at once for providers that care.
new RadonStorage({ mode: "test", providers: { /* … */ } }); // default
new RadonStorage({ mode: "live", providers: { /* … */ } });Free, Pro, and init()
Three providers are free (s3, r2, local). Everything else — and the
resumable-upload and failover features — is Pro and needs a verified license.
const storage = new RadonStorage({
providers: { supabase: { bucket: "media" } }, // a Pro provider
licenseKey: process.env.RADON_LICENSE_KEY, // or RADON_LICENSE_KEY in the env
});
await storage.init(); // verifies the license once, then unlocks Pro; throws if invalid
await storage.upload({ key: "a.png", body: buf });init() pings the license service at most once per process and caches the
result. It fails closed: a missing key throws LicenseRequiredError, and an
invalid or unreachable key throws LicenseInvalidError.
When you must call init()
init() is required whenever you use any Pro provider, a
failover chain, or a
resumable upload — even resumable upload to a
free provider like S3, because chunked upload is itself a Pro feature.
Free-only setups that only use upload/download/getUrl/… can skip it
entirely.
Escape hatch: native()
The unified API covers the common cases. For anything provider-specific, drop to
native() — it returns the adapter's own low-level machinery (its request
signer, HTTP client, and resolved endpoint config), so you can call the raw API
while staying zero-dependency:
const s3 = await storage.native("s3");
// s3.signer → SigV4Signer
// s3.http → HttpClient
// s3.settings → { bucket, region, baseUrl, publicUrl, ... }
const url = s3.signer.presign({ method: "GET", url: s3.objectUrl("k"), expiresIn: 60 });You can also register your own provider by implementing StorageProvider (or
extending BaseProvider, or S3CompatibleProvider for an S3-compatible store)
and calling registerProvider(slug, loader). Custom providers are Pro-gated but
built exactly like the built-ins.
Quickstart
Store your first file with Radon Storage in about five minutes — upload it, get a signed URL, and read it back. One provider, no branching decisions.
Uploading
Everything about storing objects with Radon — body vs. path, content-type inference, metadata, cache control, ACLs, and server-side copy.