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.
Quick reference
Section titled “Quick reference”| Method | Shape | Returns |
|---|---|---|
put | put(key, body, opts?) | Promise<void> |
get | get(key) | Promise<Uint8Array | null> |
delete | delete(key) | Promise<void> |
list | list(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" })Storing a file
Section titled “Storing a file”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" })Reading a file
Section titled “Reading a file”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" })Deleting and listing
Section titled “Deleting and listing”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")Keys are App-scoped automatically
Section titled “Keys are App-scoped automatically”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.
Large and browser-direct uploads
Section titled “Large and browser-direct uploads”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.
Signed URLs
Section titled “Signed URLs”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 directlyreturn c.json({ url: upload.url, expiresIn: upload.ttlSeconds })Multipart uploads
Section titled “Multipart uploads”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>Errors
Section titled “Errors”| Error | When it is thrown |
|---|---|
InvalidKeyError | Thrown synchronously by any method when the key violates the key rules above. |
NotAvailableInV1Error | Thrown by signedUrl and the multipart methods when the platform control plane is unavailable, or when your storage quota is exceeded. |
Things to know
Section titled “Things to know”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 fromc.envbefore your handler runs — it isundefined, and calling.put(...)on it throws. The only supported access path isc.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. IfformData()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 toFormDataunder the field name"file". Your upload handler must readform.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 returnnull. -
putanddeleteare 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.