Uploading
Everything about storing objects with Radon — body vs. path, content-type inference, metadata, cache control, ACLs, and server-side copy.
upload() stores one object. You always give it a destination key; everything
else is optional. This page covers every input and the gotchas worth knowing.
const result = await storage.upload({
key: "docs/report.pdf",
path: "./report.pdf",
});Body vs. path
There are two ways to supply the bytes, and you pick exactly one:
body— in-memory bytes (Buffer/Uint8Array), a NodeReadablestream, or a UTF-8string.path— a local file on disk that Radon reads for you.
await storage.upload({ key: "note.txt", body: "hello world" }); // string → UTF-8
await storage.upload({ key: "blob.bin", body: someBuffer }); // Buffer / Uint8Array
await storage.upload({ key: "in.dat", body: fs.createReadStream(p) }); // Readable stream
await storage.upload({ key: "report.pdf", path: "./report.pdf" }); // local fileCommon mistake: body AND path
Provide either body or path, never both — and never neither. Passing
both (or leaving both unset) throws. Pick the one that matches where your bytes
are: path for a file on disk, body for anything already in memory.
Content type
The content type (MIME type) tells browsers how to render the object.
Radon infers it from the key's — or path's — extension when you don't pass one:
.png becomes image/png, .pdf becomes application/pdf, and anything
unrecognized falls back to application/octet-stream.
await storage.upload({ key: "avatars/ada.png", body: buf }); // inferred: image/png
await storage.upload({ key: "data", body: buf, contentType: "text/csv" }); // explicit winsNo extension? Set it explicitly
Inference is by file extension only. A key like data or blob with no
extension resolves to application/octet-stream — pass contentType when the
key doesn't carry one.
Metadata
User metadata is arbitrary string key/value data stored alongside the
object. Each provider persists it in its native slot (x-amz-meta-* on the S3
family, x-ms-meta-* on Azure, x-oss-meta-* on Alibaba OSS) and
getMetadata() returns it.
await storage.upload({
key: "invoices/2026-01.pdf",
path: "./invoice.pdf",
metadata: { customerId: "cus_123", generatedBy: "billing-worker" },
});
const meta = await storage.getMetadata("invoices/2026-01.pdf");
meta.metadata.customerId; // "cus_123"Values must be strings. On the S3 family, metadata keys are lowercased.
Cache control and disposition
Two more headers ride along with the object:
cacheControlstringThe Cache-Control header stored with the object, e.g.
"public, max-age=31536000". Controls how browsers and CDNs cache it.
contentDispositionstringThe Content-Disposition header, e.g. attachment; filename="report.pdf" —
force a download instead of inline display.
await storage.upload({
key: "assets/app.js",
path: "./dist/app.js",
cacheControl: "public, max-age=31536000, immutable",
});ACLs
The acl option sets an object's visibility. It's a canned choice — providers
map it to their nearest equivalent:
acl"private" | "public-read"default: "private"private keeps the object reachable only via signed URLs. public-read
makes it world-readable, and Radon returns a public url on the result.
const { url } = await storage.upload({
key: "public/logo.png",
path: "./logo.png",
acl: "public-read",
});
url; // a public URL — present because the object is public-readACLs depend on your bucket policy
public-read only produces a working public URL if the bucket permits
object-level public access. Some stores (Cloudflare R2, for instance) are
private at the bucket level with no automatic public URL — for those, set a
publicUrl on the provider or use a signed URL.
The upload result
upload() returns a normalized UploadResult:
const r = await storage.upload({ key: "a.png", path: "./a.png" });
r.key; // "a.png"
r.provider; // "s3"
r.size; // bytes stored
r.etag; // entity tag
r.contentType; // "image/png"
r.versionId; // set on versioned buckets
r.url; // public URL when determinable (public-read / CDN provider)
r.metadata; // your metadata, echoed back
r.raw; // the untouched provider responseServer-side copy
copy() duplicates an object server-side — the bytes never round-trip
through your process:
await storage.copy("uploads/tmp/a.png", "avatars/ada.png");Not every provider can copy
Server-side copy is supported on the S3 family, Alibaba OSS, Azure, Supabase,
and the local filesystem. Providers without a copy API — Vercel Blob,
UploadThing, Bunny, ImageKit, and Cloudinary — throw UnsupportedOperationError.
Check provider.capabilities.copy first, or download-then-upload as a fallback.
Uploading to a specific provider
Pass { provider } as the second argument to target one provider for a single
call, regardless of defaultProvider:
await storage.upload({ key: "a.png", body: buf }, { provider: "r2" });Large files
For multi-gigabyte uploads, use
uploadResumable() — it chunks the file into
parts and aborts cleanly on failure. It's a Pro feature and requires
storage.init().
Core concepts
The mental model behind Radon Storage — providers, the storage contract, keys, capabilities, signed URLs, test vs live, and the native() escape hatch.
Signed URLs
Public vs. signed URLs, expiry windows, presigned PUT uploads, forced downloads, and the per-provider support caveats — all through one getUrl() method.