File uploads
Files give your app blob storage: upload a photo, a PDF, a CSV, anything, and serve it
back by URL. Where Data holds structured records, Files holds opaque
bytes. Turn on the optional image optimization and the same stored images can be resized
and re-encoded on the fly — thumbnails, avatars, responsive srcset — with no second upload.
Storing and serving files
Section titled “Storing and serving files”Files live at /api/_files/<key>, served from your app’s origin. Your app reaches them
through the files import rather than a hand-written fetch:
import { uploadFile, fileUrl, listFiles, deleteFile } from "files";
const key = `avatars/${user.id}.png`;
await uploadFile(key, input.files[0]);// → { key: "avatars/u_1.png", size: 20481, contentType: "image/png" }
avatar.src = fileUrl(key);
const { objects, cursor } = await listFiles({ prefix: "avatars/" });// → { objects: [ { key, size, contentType, uploadedAt }, … ], cursor: null }
await deleteFile(key);The file itself is the request body. Hand uploadFile the File straight off an
<input type="file"> and the raw bytes go up as they are, with the content-type read off the
file — wrapping an upload in FormData would store the multipart envelope as the file. Pass
{ contentType } only for bytes you built yourself. Keys are url-encoded segment by segment,
so a space or a # in a filename is safe.
Two more reads round it out: readFile(key) hands back the bytes as a Blob when the app
itself has to look at them, and fileInfo(key) answers { key, size, contentType } — or
null if there is no such file — without moving any bytes. A failed request throws a
FilesError carrying .status and the server’s own message: 401 sign in first, 403 not
yours, 413 too large, 429 slow down.
| Method & path | Body | Returns |
|---|---|---|
PUT /api/_files/:key |
the raw bytes; set Content-Type |
201 {"key","size","contentType"} |
GET /api/_files/:key |
— | the file bytes |
GET /api/_files |
— | {"objects":[{"key","size","contentType","uploadedAt"}],"cursor"} |
DELETE /api/_files/:key |
— | {"deleted":true} |
GET /api/_images/:key |
— | the transformed image |
That is the wire the import speaks. The app’s server.mjs is a lone module with no module
graph, so it cannot import files — there you call these same paths through
env.PLATFORM.fetch(), where POST does the same as PUT. Keys can contain /, so you can
organize files into folders (receipts/2026/03.pdf). GET /api/_images/:key is the image
endpoint below — it reads the same object and is only routed when image optimization is on.
listFiles takes { prefix, limit, cursor } — limit defaults to 100 and tops out at
1000 — and cursor comes back non-null while there is another page, so pass it back until it
is null. It is still a listing, not a query: if a user can hold more files than you want to
page through, keep the file’s metadata as a Data record (with its key)
and list that instead — you get filtering, sorting and paging for free, and the blob store
stays a place you fetch bytes from by key.
A file is capped at 100 MB, and writes (upload, delete) at 60 per minute per caller —
an app that needs more than that from one person is holding media hostage in the wrong store.
uploadFile refuses an oversized file before it sends a byte; over the wire those are a 413
and a 429.
Serving supports Range requests, conditional ETag revalidation (304) and HEAD, so
<video> seeks and repeat visits don’t re-download bytes.
Image optimization
Section titled “Image optimization”With image optimization on, /api/_images/<key> reads the same object you uploaded to
/api/_files/<key> and transforms it on the way out — no separate storage, no re-upload. The
same import builds the url:
import { imageUrl } from "files";
// a 128px-wide avatar, auto-encoded to AVIF/WebP if the browser accepts itthumb.src = imageUrl("avatars/me.png", { w: 128, fit: "cover" });// → "/api/_images/avatars/me.png?w=128&fit=cover"imageUrl builds a string and never fetches, so it goes straight into an <img>. An option
left undefined is dropped, so an optional width needs no branch.
| Option | Meaning |
|---|---|
w, h |
target width / height in px (clamped to 4000) |
fit |
scale-down, contain, pad, squeeze, cover, crop |
format |
avif, webp, jpeg, png, gif (default: negotiated from Accept) |
q |
quality, 1–100 |
rotate |
90, 180, 270 |
blur |
0–250 |
It’s read-only — writes always go through uploadFile. And it degrades gracefully: if
your account hasn’t enabled transformations, or the file isn’t a transformable image, it
serves the original bytes so an <img> never breaks.
Who can see a file
Section titled “Who can see a file”The key’s first segment decides, the same way a Data collection declares its scope:
| Key | Space |
|---|---|
public/… |
Anyone can GET it, signed in or not. Only its uploader can replace or delete it. |
shared/… |
One space every signed-in user reads and writes. |
| anything else | Private to the signed-in user — invisible to everyone else. |
Use public/ for anything one user uploads that others must see — avatars, listing photos,
post images: upload to public/avatars/<userId>.png and every visitor can render it. With
Sign-in on, a signed-out visitor gets a 401 for everything except
reading public/ keys. Uploading always needs an account — an open bucket is someone else’s
storage bill — so an app with no sign-in can serve files but not take them.
Private and shared files are served with a private cache header so a shared cache can never
hand one user’s file to another; public/ files (and their optimized images) cache publicly.
How it’s built
Section titled “How it’s built”Files are backed by a Cloudflare R2 bucket bound to your app, image optimization by Cloudflare Images, and the write throttle by a Workers rate limit binding, all in your account. Files ship as a single project-wide bucket every deploy shares. Just ask the agent for uploads — or image optimization — and it turns them on.