Radondocs

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 Node Readable stream, or a UTF-8 string.
  • path — a local file on disk that Radon reads for you.
All four body shapes
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 file

Common 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 wins

No 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:

cacheControlstring

The Cache-Control header stored with the object, e.g. "public, max-age=31536000". Controls how browsers and CDNs cache it.

contentDispositionstring

The 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-read

ACLs 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 response

Server-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().

On this page