File uploads: front-end → Worker → R2
Three layers, one flow: the browser picks a file, the Worker stores it in R2, and D1 remembers the details.
What are we wiring up?
We will build a tiny page with a file input, send the chosen file to a Worker, store the raw bytes in R2 (object storage = a service for keeping whole files like images and PDFs), record its details in D1 (a SQL database), and then serve the file back by a public URL.
Think of it like a coat check
The Worker is the coat-check desk. You hand over your coat (the file); it stores the coat in the back room (R2) and writes a ticket number in its logbook (D1). Later you show the ticket (the key) and it fetches your coat.
Who does what?
Each layer has one clear job. Keeping bytes (R2) separate from facts about the bytes (D1) is what makes the system easy to query, list, and clean up later.
Front-end
Shows a file picker and POSTs the chosen file as multipart/form-data using fetch.
Worker
Receives the upload, writes bytes to R2, records a row in D1, and streams files back on request.
R2 (bytes)
Object storage that holds the actual file bytes. Reading them back out costs $0 in egress.
D1 (facts)
A SQL database that stores who uploaded what, the filename, type, size, and time.
The metadata table
R2 only stores bytes under a key (key = the unique name/path of an object). To list a user's uploads or show a filename, we keep a small row of metadata in D1 for every object.
CREATE TABLE IF NOT EXISTS files (
key TEXT PRIMARY KEY, -- R2 object key, e.g. uploads/172...-photo.jpg
filename TEXT NOT NULL, -- original name the user picked
content_type TEXT, -- MIME type, e.g. image/png
size_bytes INTEGER, -- file size, for quotas and display
uploaded_at TEXT NOT NULL -- ISO timestamp
);Why store the key as the primary key?
The R2 key is already unique and is exactly what you need to fetch the object later, so it makes a clean primary key that ties the D1 row to the R2 object one-to-one.
Request flows
1) Uploading a file
The browser bundles the file into a multipart body (multipart/form-data = the standard way browsers package one or more files plus form fields). The Worker reads it, writes the bytes to R2 with put(), saves a row to D1, and returns a URL the page can link to.
2) Serving a file back
To view a file, the page just requests /files/<key>. The Worker calls get(), copies the stored Content-Type onto the response, and streams the bytes straight to the browser — no copy is loaded fully into memory.
Build it step by step
Wrangler is Cloudflare's command-line tool (CLI = a program you type commands into). Make sure you have run npm install -g wrangler and wrangler login first.
Create the R2 bucket and D1 database
Run both creates. Copy the database_id that wrangler prints for the D1 database — you will paste it next.
# Bucket that will hold the file bytes wrangler r2 bucket create user-uploads # Database that will hold the metadata (copy the printed database_id) wrangler d1 create uploads-metaAdd both bindings in wrangler.toml
A binding gives your code a variable to reach a resource. Here [[r2_buckets]] exposes env.BUCKET and [[d1_databases]] exposes env.DB.
name = "file-uploader" main = "src/index.js" compatibility_date = "2025-09-23" # R2 binding -> reachable as env.BUCKET in your Worker [[r2_buckets]] binding = "BUCKET" bucket_name = "user-uploads" # D1 binding -> reachable as env.DB in your Worker [[d1_databases]] binding = "DB" database_name = "uploads-meta" database_id = "paste-the-id-from-wrangler-d1-create"Create the metadata table
Run the CREATE TABLE against the real (remote) D1 database. You can also save it as schema.sql and use --file=./schema.sql instead.
wrangler d1 execute uploads-meta --remote --command "CREATE TABLE IF NOT EXISTS files (key TEXT PRIMARY KEY, filename TEXT NOT NULL, content_type TEXT, size_bytes INTEGER, uploaded_at TEXT NOT NULL);"Front-end: file input + fetch
FormData tells the browser to build a multipart/form-data body for us, so we never assemble the upload by hand.
<!-- index.html (front-end) --> <input type="file" id="file" accept="image/*,.pdf" /> <button id="send">Upload</button> <p id="status"></p> <script> const fileInput = document.getElementById("file"); const statusEl = document.getElementById("status"); document.getElementById("send").addEventListener("click", async () => { const file = fileInput.files[0]; if (!file) { statusEl.textContent = "Pick a file first."; return; } // FormData makes the browser send a multipart/form-data body for us const form = new FormData(); form.append("file", file); statusEl.textContent = "Uploading..."; const res = await fetch("/upload", { method: "POST", body: form }); const data = await res.json(); statusEl.innerHTML = `Done! <a href="${data.url}">${data.filename}</a>`; }); </script>Worker: store in R2 + record in D1 + serve
One handler does both jobs: POST /upload writes to R2 and D1; GET /files/<key> reads from R2 and streams it back.
export default { async fetch(request, env) { const url = new URL(request.url); // 1) UPLOAD: POST /upload (multipart/form-data) if (request.method === "POST" && url.pathname === "/upload") { const form = await request.formData(); const file = form.get("file"); if (!(file instanceof File)) { return Response.json({ error: "No file field" }, { status: 400 }); } // Build a unique key so two uploads never collide const key = `uploads/${Date.now()}-${crypto.randomUUID()}-${file.name}`; // Store the raw bytes in R2 (reading them back out is free) await env.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type }, }); // Remember the details in D1 (bound params = no SQL injection) await env.DB.prepare( "INSERT INTO files (key, filename, content_type, size_bytes, uploaded_at) VALUES (?, ?, ?, ?, ?)" ) .bind(key, file.name, file.type, file.size, new Date().toISOString()) .run(); return Response.json({ key, filename: file.name, url: `${url.origin}/files/${encodeURIComponent(key)}`, }); } // 2) SERVE: GET /files/<key> if (request.method === "GET" && url.pathname.startsWith("/files/")) { const key = decodeURIComponent(url.pathname.slice("/files/".length)); const object = await env.BUCKET.get(key); if (object === null) { return new Response("Not found", { status: 404 }); } // Copy the stored content-type etc. onto the response, then stream it const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("etag", object.httpEtag); return new Response(object.body, { headers }); } return new Response("Not found", { status: 404 }); }, };Deploy
Publish the Worker to Cloudflare's global network. Your upload page and /files/ endpoint go live worldwide.
wrangler deploy
Key concepts
Object storage
A service for keeping whole files (objects) instead of rows or blocks. Great for images, PDFs, and video.
Key
The unique name/path of an object, like uploads/2026/photo.jpg. You read an object back by its key.
Bucket
A named container that holds many objects, like a top-level folder for your app's files.
multipart/form-data
The standard request format browsers use to send files. FormData builds it for you automatically.
Content-Type (MIME)
A label like image/png that tells browsers how to display a file. Store it on upload and set it on serve.
Presigned URL
A temporary signed link that lets the browser upload or download directly from R2, bypassing the Worker for large files.
Tips, pitfalls & pricing
Big files? Use presigned URLs
Uploading through the Worker is simplest, but a single request body is capped (about 100 MB on the Free plan). For large files, have the Worker mint a presigned URL so the browser uploads multipart directly to R2, then save the metadata afterwards.
Zero egress is the headline
However many times users download a file from R2, serving it out to the internet costs $0 in egress — unlike most clouds, where a popular file can run up a surprise bill.
- Always make keys unique (timestamp + random id) so a new upload never overwrites an old one.
- Validate file size and type on the Worker before put() — never trust the browser alone.
- Store and re-apply Content-Type, or browsers may download files instead of showing them.
- Add CORS headers on /upload if your page is served from a different origin than the Worker.
- Avoid putting the raw user filename directly in the key; encode it or strip unusual characters.
Related products
menu_bookOfficial docsopen_in_new