Migrating to v3
What changed in files-sdk 3.0 - body-less head and list results, two new error codes, one bulk result shape, expiring URLs that never silently stay permanent, a capabilities object for adapters, and Node 22.
v3 tidies the API that grew from v0 to v2. Most of it surfaces as type errors when you upgrade. Three changes don’t show up in the types, so read those first: error codes, url({ expiresIn }), and, if you wrote your own adapter, its capabilities object.
npm install files-sdk@3pnpm add files-sdk@3yarn add files-sdk@3bun add files-sdk@3nub add files-sdk@3aube add files-sdk@3Error codes
FilesError has two new codes, split out of Provider, which now only means the backend or transport failed:
Invalid— the call itself is wrong: an empty, null-byte, or..key, a malformed range, ETag, or condition, contradictory options, bad constructor config, or a file avalidation()rule rejects (ValidationErrorkeeps itsreason).Unsupported— the call is fine, but this adapter, in this mode and with these plugins, can’t do it: arangewithout range reads,metadataon Vercel Blob,url()underencryption(), a presignedmaxSizethe provider can’t enforce.
Both are permanent, so they’re never retried. If you branch on error.code === "Provider" to catch bad input or unsupported options, check the new codes too:
try {
await files.download(key, { range: { start: 0, end: 1023 } });
} catch (error) {
if (error instanceof FilesError && error.code === "Unsupported") {
// was "Provider" in v2
return files.download(key);
}
throw error;
}
restoreVersion() (versioning()) and restoreTrashed() (softDelete()) with nothing to restore now throw NotFound.
The gateway answers Invalid with a 422 (wire code Validation, where v2 sent a 500) and Unsupported with a 422 (wire code Unsupported), and the browser client turns both back into the matching code. The files CLI exits 5 for Provider and 2 for Invalid / Unsupported / ReadOnly; v2 exited 2 for all of them.
Expiring URLs
url(key, { expiresIn }) now always returns a link that expires, or throws:
- On an adapter that can sign, it signs, even with a
publicBaseUrl. In v2 the S3 family, Bun’s S3, GCS, Azure, Firebase, Supabase, and R2 returned the permanent CDN link and ignoredexpiresIn. A plainurl(key)still returns the CDN link. - On an adapter that only has permanent links (Vercel Blob public, UploadThing
public-read, Convex, Appwrite, the filesystem, OneDrive / Google Drive in public-link mode), it throwsUnsupported. DropexpiresInto get the permanent link. Box and Dropbox in public-link mode return their tokenized (provider-timed) link instead. memory()can’t sign either, so tests that passexpiresInagainst it now getUnsupported. DropexpiresInin those tests, or assert onfiles.capabilities.signedUrl.supported.
files.capabilities has two new fields to branch on: publicUrl (a plain url(key) is a permanent link) and signedUrl.disposition (a responseContentDisposition is bound into the signature). The gateway now proxies a download it can’t sign with the forced attachment, which fixes default downloads on Vercel Blob and UploadThing in private mode, PocketBase, and Cloudinary private delivery.
head, list, and search results
head(), list(), listAll(), and search() return FileInfo — { key, size, contentType, etag?, lastModified?, metadata? } — with no body. In v2 they returned a StoredFile whose body accessors quietly downloaded the object.
// v2
const meta = await files.head(key);
meta.type;
await meta.text(); // a hidden full download
// v3
const info = await files.head(key);
info.contentType;
await (await files.download(key)).text(); // explicit
- Read
contentTypewhere you readtype, andkeywhere you readname, on head and list results. download()still returns aStoredFile, which is now aFileInfoplusname/typeand the body accessors.upload()resolves to aFileInfo(UploadResultis an alias).- So do
restoreVersion(),restoreTrashed(), and the browser client’supload(). - The gateway wire,
useFilesresults, the registry components, and the CLI / MCP / AI tool output usecontentTypetoo, and dropname. See Gateway and browser client.
Bulk results
The array forms of upload, download, head, and delete all resolve to { results, errors? }:
// v2
const { uploaded } = await files.upload(items);
const { deleted } = await files.delete(keys);
// v3
const { results: uploaded } = await files.upload(items);
const { results: deleted } = await files.delete(keys);
exists([…]) keeps { existing, missing, errors? }. DeleteManyOptions is now BulkOptions, and DeleteManyError is replaced by BulkError. A bulk delete with stopOnError: true now always deletes one key at a time and skips the native batch, so it gives the same result with or without plugins.
Gateway and browser client
Deploy the server and client together: the wire format changed, so a v2 client can’t read a v3 gateway’s responses, or the other way round.
- File records on the wire carry
contentTypeinstead oftype, and noname. - A bulk
headanswers withresultsinstead offiles, and a bulkdeletewithresultsinstead ofdeleted. - The
urloperation answers a client-requestedexpiresIn, or anauthorizemaxExpiresIn, with a422on an adapter that can’t sign (Vercel Blob public, UploadThingpublic-read, Convex, Appwrite), where v2 returned the permanent link. - Presigning uploads follows
capabilities.signedUploadinstead ofsignedUrl. The gateway presigns only when the adapter can bindmaxUploadSizeand the file’s content type, and proxies the upload otherwise. - Re-add the registry components you installed (
npx shadcn@latest add <url> --overwrite). Copies installed from v2 still readfile.typeandcaps.multipart. useFilesuploads[]entries keep the browser file’snameandtype. The upload result is aFileInfowithcontentType.- Keys and
list/searchprefixes insideversioning()’s orsoftDelete()’s storage (.versions,.trash) now get403unless they sit under anauthorizekeyPrefix. Use the plugin verbs (versions,restoreVersion,trashed,restoreTrashed,purge) instead. - Anything thrown from
authorize,onUploadComplete, or thecompletionsstore that isn’t aFilesError(orUploadRejectedError) now answers a generic500(“internal server error”) instead of carrying its message. The original goes to the newonErroroption, which defaults toconsole.error.
Capabilities
files.capabilities reads the same way, with a few field changes:
multipartis renamedresumable.delimiteris"any" | "slash" | false, so checkcaps.delimiter !== falsefor “has folders”.signedUrlgainsexpiryanddisposition.signedUpload(direct uploads),publicUrlandevents(the bucket-notification format, forfiles-sdk/events) are new.
An option the adapter can’t honor, or that a plugin turns off, is now refused before any plugin runs.
Custom adapters
Custom adapters declare their capabilities as one object instead of separate flags:
const myAdapter: Adapter = {
name: "my-store",
raw: client,
// v2: supportsRange: true, supportsDelimiter: true, signedUrl: { supported: true }
capabilities: {
rangeRead: true,
delimiter: "slash",
signedUrl: { supported: true, disposition: true },
signedUpload: { supported: true, maxSize: false, contentType: true },
},
// …methods
};
Rename the old flags: supportsRange → rangeRead, supportsDelimiter: true → delimiter: "any" (or "slash"), supportsMetadata → metadata, supportsCacheControl → cacheControl, supportsServerSideCopy → serverSideCopy, reportsUploadProgress → uploadProgress, top-level signedUrl → capabilities.signedUrl.
TypeScript flags the old fields only on an object literal typed as Adapter. Built any other way, they’re ignored without an error, and anything you leave out of capabilities is off:
- Without
signedUrl.supported,url(key, { expiresIn })throwsUnsupportedbefore yoururl()runs. - Without
signedUpload, the gateway proxies every upload. - Without
signedUrl.dispositionorpublicUrl, it proxies downloads.
Their other changes:
headandlistreturnFileInfo.deleteManyreturns{ results, errors? }.createStoredFile()takes aFileInfo, withcontentTypeinstead oftype(StoredFileMetais removed).- Throw
Invalid/Unsupportedfor bad calls and refusals.
Custom plugins
- The
capabilities(caps)hook sees the v3 snapshot:resumable(wasmultipart), thedelimiterenum, and the newsignedUpload,publicUrl, andevents. - A plugin that transforms bodies must also turn off
signedUpload.supported: the gateway presigns uploads from it, and a direct upload would skip the transform. - Refuse a call with
InvalidorUnsupported, notProvider. The gateway falls back to its proxy only for those two, so aProviderrefusal fromsignedUploadUrlfails the upload instead. - An unsupported
range,metadata,cacheControl,control, ordelimiter, and an expiringurl()on an instance that can’t sign, are refused before anywrapruns, so awrapno longer sees those calls. wrapresults forheadanduploadareFileInfo, and so arelistitems. Bulk calls still reachwrapone key at a time; anextendmethod that calls the array forms gets{ results, errors? }back.- A new optional
eventhook maps storage events into what your plugin’s callers see.
New in v3: upload lifecycle and storage events
Nothing to migrate, but two additions replace code many apps wrote by hand:
onUploadCompleteon the gateway runs on your server once per verified upload, hands data back to the browser as the upload result’sdata, and rejects an upload by throwing. If you built a “confirm upload” endpoint for the client to call after it uploads, move that work into the hook.files-sdk/eventsnormalizes your provider’s bucket notifications (S3, R2, GCS, Azure, MinIO and more) into oneFileEventand routes them to handlers, for writes that don’t go through the SDK. Custom adapters can declarecapabilities.eventsto opt in.
Smaller changes
- Node 22 or later (Node 20 is end-of-life).
- The filesystem adapter’s
defaultUrlExpiresInoption is removed; it was ignored. new Files({ prefix: "" })(or"/") now means no prefix instead of throwing.s3()presigned PUTs now signContent-Type. Send the returnedheadersunchanged, or the upload gets a 403.signedUploadUrl({ minSize })withoutmaxSizethrowsUnsupportedons3(),s3Fetch(), R2, and Bun S3, instead of silently dropping the floor. Ons3(), passmaxSizetoo to get a POST policy that enforces both.- Google Drive
upload({ cacheControl })throwsUnsupported. Drive never served a stored Cache-Control, andcapabilities.cacheControlis nowfalse. TransferProgress/SyncProgressstatusgains"failed", so an exhaustiveswitchneeds the new case.cache()with a customstoreneeds anamespacenaming the bucket it caches (cache({ store, namespace: "uploads-prod" })), or it throwsInvalid. Store keys now include the namespace and the instanceprefix, so a persistent store’s v2 entries are no longer read (clear them, or let them expire). Reusing onecache()on an instance with a different adapter orprefixthrowsInvalidtoo; create one per instance.