Product Docs Pricing Changelog
Start free Sign in
Docs / Guides / File uploads, end to end

File uploads, end to end

The full loop for "user uploads an avatar and sees it back." Bytes go browser → R2 directly — never through Flarelink or your server. Your server only mints short-lived presigned URLs and records the object key. Four steps:

  1. Browser asks your server for a presigned PUT.
  2. Server authorizes the user, mints the URL, returns it.
  3. Browser PUTs the file straight to R2 (with progress), then tells your server the key.
  4. Server records the key in D1; later, mints a presigned GET to display it.

1 + 2. Server: authorize & mint the presigned PUT

Resolve the user first (the service key has full R2 access — your route is the authorization boundary), then namespace the key under the user's id so one user can't overwrite another's files.

// POST /api/uploads/presign — server route (framework-agnostic shape) const me = await flarelink.auth.getMe() if (!me) return json({ error: "Unauthorized" }, 401) const { filename, contentType } = await req.json() const key = `avatars/${me.id}/${crypto.randomUUID()}-${filename}` // per-user prefix const { url, signedHeaders } = await flarelink.storage .from("uploads") .createSignedUploadUrl(key, { contentType, expiresIn: 600 }) return json({ url, signedHeaders, key })

3. Browser: PUT to R2 with a progress bar

Send exactly signedHeaders — adding or dropping a header changes the SigV4 signature and R2 returns 403 SignatureDoesNotMatch. fetch can't report upload progress, so use XMLHttpRequest when you want a progress bar.

// browser async function uploadAvatar(file: File, onProgress: (pct: number) => void) { // 1. ask our server for a presigned PUT const res = await fetch("/api/uploads/presign", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type }), }) const { url, signedHeaders, key } = await res.json() // 2. PUT straight to R2, reporting progress await new Promise<void>((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("PUT", url) for (const [k, v] of Object.entries(signedHeaders)) xhr.setRequestHeader(k, v as string) xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total) xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`R2 ${xhr.status}`))) xhr.onerror = () => reject(new Error("upload failed (CORS?)")) xhr.send(file) }) // 3. tell our server the upload landed await fetch("/api/uploads/commit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key }), }) }
CORS. The browser→R2 PUT is cross-origin, so the bucket's CORS must allow your app's origin. Flarelink keeps R2 CORS in sync with your auth Worker's trusted origins automatically — if uploads fail the onerror path, make sure your origin is in the Auth panel's trusted origins. See Storage.

4a. Server: record the key in D1

On commit, re-authorize and verify the key belongs to this user (defense in depth — never trust a key the client supplies without checking its prefix), then store it.

// POST /api/uploads/commit — server route const me = await flarelink.auth.getMe() if (!me) return json({ error: "Unauthorized" }, 401) const { key } = await req.json() if (!key.startsWith(`avatars/${me.id}/`)) return json({ error: "bad key" }, 400) await flarelink.from("avatars").insert({ user_id: me.id, r2_key: key }) return json({ ok: true })

4b. Server: presign a GET to display it

To show the file, look up the key for the user and mint a short-lived download URL. Hand the URL to the browser; drop it straight into an <img>.

// GET /api/me/avatar — server route const me = await flarelink.auth.getMe() if (!me) return json({ error: "Unauthorized" }, 401) const { rows } = await flarelink .from<{ r2_key: string }>("avatars") .where({ user_id: me.id }) .orderBy("id", "desc") .limit(1) if (!rows.length) return json({ url: null }) const { url } = await flarelink.storage .from("uploads") .createSignedDownloadUrl(rows[0].r2_key) return json({ url }) // browser: <img src={url} />

Download URLs expire (default 300s, clamped to [60, 3600]), so mint them on demand rather than caching them in long-lived markup. To delete, call flarelink.storage.from("uploads").remove([key]) from a server route after the same prefix check. Reference: Storage · Limits.

Something unclear or missing? hello@flarelink.dev llms-full.txt ↗