Storage bindings
Store exports in R2
Generate a JSON backup and store it as an R2 object instead of placing large binary or file content in D1 or KV.
R2 is object storage. Think files: images, PDFs, backups, anything big or binary. Link Vault uses it for export files. A user asks for a backup, we write a JSON file to R2, and they download it later.
Don’t put files in D1 or KV. D1 rows and KV values have size limits and are priced for small data. R2 is priced for storage and has no egress fees, which is the whole reason it exists.
Create the bucket
npx wrangler r2 bucket create link-vault-exports
{
"r2_buckets": [
{ "binding": "EXPORTS", "bucket_name": "link-vault-exports" }
]
}
Run npx wrangler types and env.EXPORTS is ready.
Write an export
Give each object a structured key and set its content type. The key is a path-like string, and the prefix keeps one user’s exports together:
const key = `exports/${userId}/${crypto.randomUUID()}.json`
await env.EXPORTS.put(key, JSON.stringify({ links }), {
httpMetadata: { contentType: 'application/json' }
})
httpMetadata is stored with the object. When we read it back, we can copy it straight into the response headers.
Read it back as a stream
get() returns null for a missing key, or an object with a body stream:
const object = await env.EXPORTS.get(key)
if (!object) return c.json({ error: 'Not found' }, 404)
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('content-disposition', 'attachment; filename="links.json"')
return new Response(object.body, { headers })
Passing object.body directly to Response streams the file through. No await object.text(), no second copy in memory. For a large export that’s the difference between working and hitting the memory limit.
A key is an address, not a permission
This is the security point of the lesson. The user who is logged in decides which keys are reachable. Resolve the user first, then build the key from their internal ID. Never take a path parameter and pass it straight to get(). ../../other-user/backup.json in a URL should never become a bucket read.
Order the writes
Write the R2 object first, then mark the export as ready in D1. Or store the export row as pending, write the object, then flip it to ready. If the Worker fails between the two steps, the state tells you exactly what to retry or clean up. Do it the other way around and you have a “ready” export that 404s.
Deleting is application behavior too. Decide whether replacing an export deletes the previous object, and write it down.
Now wire it up: a POST /api/exports route that writes one object, and a GET /api/exports/:id route that checks ownership in D1 and streams the file. Test three cases separately: an unknown ID returns 404, another user’s ID returns 404 as well, and your own ID downloads links.json with content-type: application/json.
Lesson completed