claude-start-cf

R2 Storage

Cloudflare R2 is S3-compatible object storage with zero egress fees. Accessed via getBucket() inside server functions.

Drag & drop image upload

bucket.put(key, arrayBuffer, { httpMetadata })

Drop images onto the zone below or click to browse. Files are read as base64 on the client, decoded to bytes on the server, and stored in R2 under the images/ prefix.

Drag & drop images, or click to browse

PNG, JPG, GIF, WebP, SVG

// Client: read file as base64
function fileToBase64(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => {
      const dataUrl = reader.result as string
      resolve(dataUrl.split(',')[1]) // strip data:...;base64, prefix
    }
    reader.onerror = reject
    reader.readAsDataURL(file)
  })
}

// Server: decode and upload to R2
const uploadImage = createServerFn({ method: 'POST' })
  .handler(async ({ data }) => {
    const bucket = getBucket()
    const bytes = Uint8Array.from(atob(data.base64), c => c.charCodeAt(0))
    await bucket.put(`images/${data.name}`, bytes, {
      httpMetadata: { contentType: data.type },
    })
  })

Image gallery

bucket.list({ prefix: 'images/' })

Images are served via an API route at /api/r2?key=... that streams the R2 object body with the correct Content-Type header. Click an image to view full size.

No images yet. Upload some above.

// API route: src/routes/api/r2.ts
// Serves R2 objects by key query parameter
export const Route = createFileRoute('/api/r2')({
  server: {
    handlers: {
      GET: async ({ request }) => {
        const url = new URL(request.url)
        const key = url.searchParams.get('key')
        const bucket = getBucket()
        const obj = await bucket.get(key)
        if (!obj) return new Response('Not found', { status: 404 })

        return new Response(obj.body, {
          headers: {
            'Content-Type': obj.httpMetadata?.contentType || 'application/octet-stream',
            'Cache-Control': 'public, max-age=3600',
          },
        })
      },
    },
  },
})

// Usage in JSX:
// <img src={`/api/r2?key=${encodeURIComponent(imageKey)}`} />

Upload an object

bucket.put(key, body, options)

Uses a POST server function. The key is the object path (supports slashes for folder-like structure). Content-type metadata is set via httpMetadata.

const uploadText = createServerFn({ method: 'POST' })
  .inputValidator((data: { key: string; content: string }) => {
    if (!data.key.trim()) throw new Error('Key is required')
    return data
  })
  .handler(async ({ data }) => {
    const bucket = getBucket()
    await bucket.put(data.key, data.content, {
      httpMetadata: { contentType: 'text/plain' },
    })
    return { key: data.key }
  })

Objects in bucket

bucket.list({ limit: 50 })

Loaded via the route loader on first render (SSR). Each object shows its key, size, and upload date. The delete button calls bucket.delete(key).

hi.txt

10 B2/19/2026, 10:04:39 PM

Read an object

bucket.get(key) → obj.text()

Fetch an object by key via a GET server function. The R2Object body can be consumed as text, json, arrayBuffer, or a ReadableStream.

const getObject = createServerFn({ method: 'GET' })
  .inputValidator((data: { key: string }) => data)
  .handler(async ({ data }) => {
    const bucket = getBucket()
    const obj = await bucket.get(data.key)
    if (!obj) throw new Error('Not found')

    const text = await obj.text()        // or .json(), .arrayBuffer()
    return {
      body: text,
      size: obj.size,
      contentType: obj.httpMetadata?.contentType,
    }
  })

Accessing R2 via getBucket()

src/lib/env.ts

The env helper provides typed access to the R2 bucket binding. Import getBucket() in any server function.

// src/lib/env.ts
import { env } from 'cloudflare:workers'

export function getEnv(): Env {
  return env as Env
}

export function getBucket(): R2Bucket {
  return getEnv().BUCKET
}
// In any server function
import { getBucket } from '~/lib/env'

const myServerFn = createServerFn().handler(async () => {
  const bucket = getBucket()

  // Upload
  await bucket.put('key', 'value')

  // Read
  const obj = await bucket.get('key')
  const text = await obj?.text()

  // List
  const { objects } = await bucket.list({ prefix: 'notes/' })

  // Delete
  await bucket.delete('key')
})
MethodReturnsUse Case
put(key, value)R2ObjectUpload / overwrite
get(key)R2ObjectBody | nullRead with body stream
head(key)R2Object | nullMetadata only, no body
list(options)R2ObjectsPaginated listing with prefix filter
delete(key)voidRemove object

Setup checklist

wrangler.jsonc + wrangler types

Steps to add R2 to a new project.

  1. Create the bucket
    wrangler r2 bucket create my-bucket
  2. Add the binding to wrangler.jsonc
    "r2_buckets": [{
      "binding": "BUCKET",
      "bucket_name": "my-bucket"
    }]
  3. Regenerate types
    wrangler types
  4. Use getBucket() in server functions
    const bucket = getBucket()
    await bucket.put('hello.txt', 'Hello, R2!')

Binary uploads: For file uploads from the browser, send the file as FormData to a POST server function. Use request.formData() to parse it, then pass the File/ArrayBuffer directly to bucket.put(). R2 accepts strings, ArrayBuffers, ReadableStreams, and Blobs.