Skip to content

Object Storage

Object Storage gives your App a file store for content your Visitors upload — images, documents, attachments, and anything else that is not relational data.

Your App code always calls sdk.storage.*. The SDK’s four core methods use the App’s managed storage binding. Console actions such as object browsing, metadata, direct-upload URLs, signed URLs, quota, lifecycle, and provider status go through the platform control plane. Your App never holds storage credentials and never talks to an external storage provider directly.

There is nothing to set up. Object Storage is always available to every App — no provider account, no set_secret, and no plan flag. You consume it through the SDK and the platform provisions the rest.

MethodShapeReturns
putput(key, body, opts?)Promise<void>
getget(key)Promise<Uint8Array | null>
deletedelete(key)Promise<void>
listlist(prefix?)Promise<{ key, size }[]>

You reach these through the SDK inside a handler:

const sdk = c.get("sdk")
await sdk.storage.put("invoices/2026-06.pdf", bytes, { contentType: "application/pdf" })

put writes an object under a key. The body can be an ArrayBuffer, an ArrayBufferView, a ReadableStream, or a string. Pass contentType so downloads serve with the right MIME type.

put(
key: string,
body: ArrayBuffer | ArrayBufferView | ReadableStream | string,
opts?: { contentType?: string },
): Promise<void>
const sdk = c.get("sdk")
await sdk.storage.put("avatars/user-42.png", bytes, { contentType: "image/png" })

get returns the object’s bytes, or null when the key does not exist. It never throws for a missing key — check for null before using the result.

get(key: string): Promise<Uint8Array | null>
const sdk = c.get("sdk")
const bytes = await sdk.storage.get("avatars/user-42.png")
if (bytes === null) {
return c.json({ error: "not found" }, 404)
}
return c.body(bytes, 200, { "content-type": "image/png" })

delete removes an object. list returns every object whose key begins with the given prefix; with no prefix, it lists every object in your App.

delete(key: string): Promise<void>
list(prefix?: string): Promise<{ key: string; size: number }[]>
const sdk = c.get("sdk")
const docs = await sdk.storage.list("invoices/")
// → [{ key: "invoices/2026-06.pdf", size: 18244 }, ...]
await sdk.storage.delete("invoices/2026-06.pdf")

The SDK prefixes every key with your App’s identity before any I/O. Do not put your App id in the key yourself. Two different Apps can both use the key "profile.png" without colliding.

Production objects keep the stable production namespace. Preview Apps use a separate preview namespace, so preview uploads do not appear in production and are not copied into production when you promote.

Keys are validated before every operation. A valid key is:

  • Non-empty and at most 512 characters.
  • Made up only of the characters a–z, A–Z, 0–9, ., _, /, and -.
  • Free of a leading or trailing /.
  • Free of empty segments, . segments, and .. segments (no path traversal).

An invalid key throws InvalidKeyError synchronously, before any storage call is made.

For files too large to stream through a handler, ask the platform for a pre-authorized URL and let the browser upload straight to the storage backend — your handler never proxies the bytes.

signedUrl returns a short-lived, pre-authorized URL for a single operation. The default method is "read" and the default TTL is 60 seconds. The server hard-clamps the TTL at 900 seconds no matter what you pass.

signedUrl(key: string, opts?: StorageSignedUrlOpts): Promise<StorageSignedUrl>
type StorageSignedUrlOpts = {
method?: "read" | "write" | "delete" // default "read"
ttlSeconds?: number // default 60, server-clamped max 900
contentType?: string
declaredSize?: number
}
type StorageSignedUrl = {
url: string
method: "read" | "write" | "delete"
ttlSeconds: number
}
const sdk = c.get("sdk")
const upload = await sdk.storage.signedUrl("uploads/big-video.mp4", {
method: "write",
contentType: "video/mp4",
})
// hand `upload.url` to the browser; it PUTs the file directly
return c.json({ url: upload.url, expiresIn: upload.ttlSeconds })

For resumable uploads, drive a multipart session: open it with initiateMultipart, request a pre-authorized URL per part with uploadPart, then finish with completeMultipart — or abortMultipart if something fails.

initiateMultipart(
key: string,
opts: { contentType: string; declaredSize: number },
): Promise<{ uploadId: string; expiresAt: string }>
uploadPart(
key: string,
args: { uploadId: string; partNumber: number; ttlSeconds?: number },
): Promise<{ url: string; ttlSeconds: number }>
completeMultipart(
key: string,
args: { uploadId: string; parts: { partNumber: number; etag: string; size: number }[] },
): Promise<{ etag: string }>
abortMultipart(key: string, args: { uploadId: string }): Promise<void>
ErrorWhen it is thrown
InvalidKeyErrorThrown synchronously by any method when the key violates the key rules above.
NotAvailableInV1ErrorThrown by signedUrl and the multipart methods when the platform control plane is unavailable, or when your storage quota is exceeded.

These are the details that most often trip people up when wiring storage into handlers.

  • Always go through sdk.storage. The raw storage binding (c.env.APP_STORAGE) is stripped from c.env before your handler runs — it is undefined, and calling .put(...) on it throws. The only supported access path is c.get("sdk").storage.

  • Wrap c.req.formData() in try/catch. The platform’s post-deploy health probe sends a non-multipart JSON body ({}) to your mutating endpoints. If formData() throws and you do not catch it, the resulting 500 fails the build’s verify gate. Catch the parse error and return a 400 instead.

  • The upload field name is "file". Generated client components append the file to FormData under the field name "file". Your upload handler must read form.get("file") by that exact name — any other name silently breaks every upload UI the Builder generates.

  • Persist the storage key, then read it back. Upload and download handlers share no in-memory state. A download handler can only reconstruct a key from your database. Either use the database record’s primary id as the storage key verbatim, or save the exact key in a column and read it back before calling get. A mismatched key makes every download return null.

  • put and delete are audited automatically. The SDK records each write and delete as a fire-and-forget audit event. This never changes the return value or the performance of the call.

  • Provider choice is invisible to your code. Switching the platform provider from R2 to S3, GCS, or Azure changes nothing in your App — the sdk.storage.* calls are identical, and the provider SDKs never enter your App’s bundle, so your App stays edge-slim regardless.

  • Use the Object Storage console for operations. The routed console can browse production and preview targets, navigate provider-backed prefixes, inspect metadata, upload, rename, delete, create signed URLs, view quota/reconciliation state, and review lifecycle/provider status. Org members can browse production read-only; owner/admin roles are required for preview reads and mutations.