API reference
Every method is available on the Files instance. The unified surface only covers what every adapter can do cleanly - anything provider-specific lives on files.raw.
Functions
files.upload(key, body, options?)
Writes a body to key. Accepts native File, Blob, ReadableStream, ArrayBuffer, or string. Content type is inferred from the input when possible.
await files.upload("avatars/abc.png", file, { contentType: "image/png", cacheControl: "public, max-age=31536000", metadata: { userId: "123" },});// → { key, size, contentType, etag, lastModified }files.download(key, options?)
Reads an object. Returns a StoredFile by default (Blob-backed). Pass { as: "stream" } to opt into a ReadableStream for large objects.
const file = await files.download("avatars/abc.png");// → StoredFile (Blob-backed)const stream = await files.download("avatars/abc.png", { as: "stream" });// → ReadableStreamfiles.head(key)
Returns the same StoredFile shape as download, without materializing the body. Calling a body accessor on the result lazy-fetches.
const info = await files.head("avatars/abc.png");// → StoredFile with no body materializedfiles.exists(key)
Checks whether an object exists without fetching its body. Returns true when the key exists and false when the provider reports NotFound. Permission, auth, and transport failures still throw so callers do not accidentally treat them as a missing file.
const present = await files.exists("avatars/abc.png");const missing = await files.exists("avatars/missing.png");// → true / falsefiles.delete(key)
Removes an object. No-op friendly: a missing key resolves successfully on providers that treat delete as idempotent, and throws FilesError with code: "NotFound" on ones that don't.
await files.delete("avatars/abc.png");files.copy(from, to)
Server-side copy where the provider supports it; falls back to read + write otherwise.
await files.copy("avatars/abc.png", "avatars/abc.bak.png");files.list(options?)
Cursor-paginated listing with prefix filter. Each item is a StoredFile with a lazy body accessor.
const { items, cursor } = await files.list({ prefix: "avatars/", limit: 100,});if (cursor) { const next = await files.list({ prefix: "avatars/", cursor });}files.url(key, options?)
Returns a URL the caller can use to fetch key. Every adapter returns the most direct URL it can produce. Signing adapters (S3 and the S3-compatible catalog — R2 over HTTP, GCS via S3 interop, plus every regional / budget / decentralised wrapper — alongside Azure with shared key, Supabase, UploadThing in private mode, and R2 binding when HTTP credentials are also configured) sign a GetObject - defaulting to a 1-hour expiry, override per-call via { expiresIn } or per-adapter via defaultUrlExpiresIn. If the adapter is constructed with a publicBaseUrl (CDN, custom domain, r2.dev, Bunny Pull Zone) or UploadThing's public-read ACL, that wins and the URL is built without signing.
Three configurations have no URL primitive and throw: Vercel Blob in access: "private" mode, an R2 Workers binding without either publicBaseUrl or HTTP credentials, and Bunny Storage without publicBaseUrl because the Storage API URL requires an AccessKey header.
// One call, every adapter. S3 and the S3-compatible catalog (R2 over HTTP,// GCS via S3 interop, plus every regional / budget / decentralised wrapper)// sign a GetObject (1h default, override with { expiresIn }); Azure signs a// SAS read URL with the same default; Supabase signs via createSignedUrl// (or returns the public URL when constructed with public:true); Vercel Blob// (public), UploadThing (public-read), and Bunny Storage with publicBaseUrl// return their CDN URLs. If you configured `publicBaseUrl` on the adapter, that// wins and signing is skipped.const url = await files.url("avatars/abc.png");const short = await files.url("avatars/abc.png", { expiresIn: 60 });// Force download (defeat stored XSS from user-uploaded HTML/SVG).// Forces signing even if `publicBaseUrl` is configured - a permanent// CDN URL has no signature to bind the override into, and silently// dropping a security ask would be a regression.const safe = await files.url("avatars/abc.png", { responseContentDisposition: "attachment",});files.signedUploadUrl(key, options)
Returns a discriminated PUT-or-POST contract so a client (typically a browser) can upload directly to the bucket without proxying bytes through your server. The flow is: your server calls signedUploadUrl(), returns the result to the browser, the browser uploads straight to the provider directly. Bandwidth and CPU stay off your server.
Without maxSize, the adapter returns a presigned PUT URL - simpler, but with no server-side size cap. With maxSize, the adapter switches to a presigned POST form whose policy enforces the size at the bucket via content-length-range. In practice you should always pass maxSize - without it, anyone with the URL can DoS your storage costs until expiresIn elapses.
Vercel Blob, Bunny Storage, Appwrite, and PocketBase throw here - Vercel's upload model goes through handleUpload() from @vercel/blob/client instead of presigned URLs, Bunny Storage writes require the Storage API AccessKey header, and Appwrite/PocketBase have no presigned upload primitive at all (mint a short-lived auth token for the client instead). The R2 Workers binding throws unless you've configured hybrid mode (binding + HTTP credentials). Azure, Supabase, and UploadThing return PUT URLs but treat maxSize as advisory rather than enforced — Azure and Supabase have no content-length-range equivalent (Azure throws on the option, Supabase throws too), and UploadThing enforces caps via the file-router config tied to the adapter's slug instead of via the URL signature. Enforce upload caps at your application gateway (or at the provider's dashboard-level bucket/route setting).
// On your server: hand back an upload contract that lets the browser// PUT/POST the file directly to the bucket. Bytes never touch your server.const upload = await files.signedUploadUrl("avatars/abc.png", { expiresIn: 60, contentType: "image/png", maxSize: 5_000_000,});// → { method: "PUT", url, headers? }// | { method: "POST", url, fields }// In the browser: PUT path (no maxSize) is a plain fetch.await fetch(upload.url, { method: "PUT", body: file, headers: upload.headers,});// POST path (with maxSize) is multipart with the signed policy fields.const form = new FormData();for (const [k, v] of Object.entries(upload.fields)) form.append(k, v);form.append("file", file);await fetch(upload.url, { method: "POST", body: form });files.file(key)
Returns a FileHandle bound to key: a thin wrapper that exposes upload, download, head, exists, delete, url, signedUploadUrl, copyTo, and copyFrom without re-passing the key each time. Useful when application code works with the same object repeatedly. The key is validated at construction; every method routes through the same Files entry points, so adapters do not implement anything extra.
const avatar = files.file("avatars/abc.png");await avatar.upload(file, { contentType: "image/png" });if (await avatar.exists()) { const meta = await avatar.head(); const url = await avatar.url({ expiresIn: 300 });}await avatar.copyTo("avatars/abc.bak.png");await avatar.delete();The StoredFile type
Native File covers name, size, type, and lastModified, but storage adds three things it doesn't carry: a full key, an etag for cache validation, and user-defined metadata. StoredFile mirrors File's shape and adds those.
interface StoredFile { // File-shaped: name: string; // = key size: number; type: string; // = contentType lastModified?: number; arrayBuffer(): Promise<ArrayBuffer>; text(): Promise<string>; stream(): ReadableStream; blob(): Promise<Blob>; // Storage-specific: key: string; etag?: string; metadata?: Record<string, string>;}upload accepts a native File as input. download, head, and list all return StoredFile. The body accessors on results from head and list lazy-fetch on call.
Errors
All methods throw a single FilesError with a normalized code. The original provider error is attached as cause.
import { FilesError } from "files-sdk";try { await files.download("missing.png");} catch (err) { if (err instanceof FilesError && err.code === "NotFound") { // handle gracefully } throw err;}Codes
"NotFound"- key does not exist."Unauthorized"- credentials missing, expired, or insufficient."Conflict"- precondition failed, e.g. conditional write lost a race."Provider"- anything else; inspectcausefor the underlying error.
Escape hatch
When you need a feature outside the unified surface - S3 versioning, lifecycle rules, ACLs, multipart, anything - drop down to the native client. The raw property is typed per adapter, so you keep autocomplete.
// Typed per adapter - S3Client, R2Bucket, VercelBlobClient, ...const s3 = files.raw;await s3.send( new PutObjectAclCommand({ Bucket: "uploads", Key: "a.png", ACL: "public-read" }),);