Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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@3
pnpm add files-sdk@3
yarn add files-sdk@3
bun add files-sdk@3
nub add files-sdk@3
aube add files-sdk@3

Error 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 a validation() rule rejects (ValidationError keeps its reason).
  • Unsupported — the call is fine, but this adapter, in this mode and with these plugins, can’t do it: a range without range reads, metadata on Vercel Blob, url() under encryption(), a presigned maxSize the 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 ignored expiresIn. A plain url(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 throws Unsupported. Drop expiresIn to 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 pass expiresIn against it now get Unsupported. Drop expiresIn in those tests, or assert on files.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 contentType where you read type, and key where you read name, on head and list results.
  • download() still returns a StoredFile, which is now a FileInfo plus name / type and the body accessors.
  • upload() resolves to a FileInfo (UploadResult is an alias).
  • So do restoreVersion(), restoreTrashed(), and the browser client’s upload().
  • The gateway wire, useFiles results, the registry components, and the CLI / MCP / AI tool output use contentType too, and drop name. 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 contentType instead of type, and no name.
  • A bulk head answers with results instead of files, and a bulk delete with results instead of deleted.
  • The url operation answers a client-requested expiresIn, or an authorize maxExpiresIn, with a 422 on an adapter that can’t sign (Vercel Blob public, UploadThing public-read, Convex, Appwrite), where v2 returned the permanent link.
  • Presigning uploads follows capabilities.signedUpload instead of signedUrl. The gateway presigns only when the adapter can bind maxUploadSize and 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 read file.type and caps.multipart.
  • useFiles uploads[] entries keep the browser file’s name and type. The upload result is a FileInfo with contentType.
  • Keys and list/search prefixes inside versioning()’s or softDelete()’s storage (.versions, .trash) now get 403 unless they sit under an authorize keyPrefix. Use the plugin verbs (versions, restoreVersion, trashed, restoreTrashed, purge) instead.
  • Anything thrown from authorize, onUploadComplete, or the completions store that isn’t a FilesError (or UploadRejectedError) now answers a generic 500 (“internal server error”) instead of carrying its message. The original goes to the new onError option, which defaults to console.error.

Capabilities

files.capabilities reads the same way, with a few field changes:

  • multipart is renamed resumable.
  • delimiter is "any" | "slash" | false, so check caps.delimiter !== false for “has folders”.
  • signedUrl gains expiry and disposition.
  • signedUpload (direct uploads), publicUrl and events (the bucket-notification format, for files-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 }) throws Unsupported before your url() runs.
  • Without signedUpload, the gateway proxies every upload.
  • Without signedUrl.disposition or publicUrl, it proxies downloads.

Their other changes:

  • head and list return FileInfo.
  • deleteMany returns { results, errors? }.
  • createStoredFile() takes a FileInfo, with contentType instead of type (StoredFileMeta is removed).
  • Throw Invalid / Unsupported for bad calls and refusals.

Custom plugins

  • The capabilities(caps) hook sees the v3 snapshot: resumable (was multipart), the delimiter enum, and the new signedUpload, publicUrl, and events.
  • 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 Invalid or Unsupported, not Provider. The gateway falls back to its proxy only for those two, so a Provider refusal from signedUploadUrl fails the upload instead.
  • An unsupported range, metadata, cacheControl, control, or delimiter, and an expiring url() on an instance that can’t sign, are refused before any wrap runs, so a wrap no longer sees those calls.
  • wrap results for head and upload are FileInfo, and so are list items. Bulk calls still reach wrap one key at a time; an extend method that calls the array forms gets { results, errors? } back.
  • A new optional event hook 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:

  • onUploadComplete on the gateway runs on your server once per verified upload, hands data back to the browser as the upload result’s data, 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/events normalizes your provider’s bucket notifications (S3, R2, GCS, Azure, MinIO and more) into one FileEvent and routes them to handlers, for writes that don’t go through the SDK. Custom adapters can declare capabilities.events to opt in.

Smaller changes

  • Node 22 or later (Node 20 is end-of-life).
  • The filesystem adapter’s defaultUrlExpiresIn option is removed; it was ignored.
  • new Files({ prefix: "" }) (or "/") now means no prefix instead of throwing.
  • s3() presigned PUTs now sign Content-Type. Send the returned headers unchanged, or the upload gets a 403.
  • signedUploadUrl({ minSize }) without maxSize throws Unsupported on s3(), s3Fetch(), R2, and Bun S3, instead of silently dropping the floor. On s3(), pass maxSize too to get a POST policy that enforces both.
  • Google Drive upload({ cacheControl }) throws Unsupported. Drive never served a stored Cache-Control, and capabilities.cacheControl is now false.
  • TransferProgress / SyncProgress status gains "failed", so an exhaustive switch needs the new case.
  • cache() with a custom store needs a namespace naming the bucket it caches (cache({ store, namespace: "uploads-prod" })), or it throws Invalid. Store keys now include the namespace and the instance prefix, so a persistent store’s v2 entries are no longer read (clear them, or let them expire). Reusing one cache() on an instance with a different adapter or prefix throws Invalid too; create one per instance.

Last updated on

Was this page helpful?