Most "R2 quickstart" guides show you the dashboard and stop. Production R2 is the upload pipeline below: the client never receives the secret key, the file goes straight to R2 without proxying through your server, and the canonical metadata row gets written only after the upload completes. Three files; no surprise costs, no leaked credentials, no orphaned files in the bucket.
1. Pre-signed upload URL
A Server Action mints a pre-signed PUT URL with a short TTL. The client uploads directly to R2 using that URL; the server never sees the bytes.
// app/[lang]/(app)/upload/actions.ts
'use server'
import { z } from 'zod'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { randomUUID } from 'crypto'
import { r2 } from '@/lib/r2/client'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
filename: z.string().min(1).max(255),
contentType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
size: z.number().int().positive().max(50 * 1024 * 1024),
})
export async function requestUploadUrl(
raw: unknown,
): Promise<{ ok: true; key: string; url: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
const key = `${session.tenantId}/${randomUUID()}-${parsed.data.filename}`
const url = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: parsed.data.contentType,
ContentLength: parsed.data.size,
}),
{ expiresIn: 60 },
)
return { ok: true, key, url }
}
2. The R2 client
The AWS SDK points at R2 with one config change: the endpoint. Everything else stays standard, so existing AWS tooling and CLIs work without modification.
// src/lib/r2/client.ts
import { S3Client } from '@aws-sdk/client-s3'
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
3. The client uploads directly to R2
The browser uses the pre-signed URL with a single PUT request. The progress event drives the UI; no server round-trip per chunk.
// app/[lang]/(app)/upload/UploadForm.tsx
'use client'
import { useState } from 'react'
import { requestUploadUrl } from './actions'
import { confirmUpload } from './confirm'
export function UploadForm() {
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
async function handleFile(file: File) {
setError(null)
setProgress(0)
const reqResult = await requestUploadUrl({
filename: file.name,
contentType: file.type,
size: file.size,
})
if (!reqResult.ok) {
setError(reqResult.error)
return
}
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', reqResult.url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve()
else reject(new Error(`R2 returned ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('upload failed')))
xhr.send(file)
})
await confirmUpload({ key: reqResult.key, size: file.size })
}
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
{progress > 0 && progress < 100 ? <progress value={progress} max={100} /> : null}
{error ? <p role="alert">{error}</p> : null}
</div>
)
}
4. The confirm step writes the metadata row
The client tells the server "the upload succeeded for this key". The server verifies the object exists in R2 (HEAD request), then writes the canonical files row.
// app/[lang]/(app)/upload/confirm.ts
'use server'
import { HeadObjectCommand } from '@aws-sdk/client-s3'
import { z } from 'zod'
import { r2 } from '@/lib/r2/client'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
key: z.string().min(1).max(500),
size: z.number().int().positive(),
})
export async function confirmUpload(
raw: unknown,
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Bind the key to the authenticated tenant; the prefix encodes the tenant.
if (!parsed.data.key.startsWith(`${session.tenantId}/`)) {
return { ok: false, error: 'invalid key' }
}
// Verify the object actually landed in R2.
let head
try {
head = await r2.send(
new HeadObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: parsed.data.key,
}),
)
} catch {
return { ok: false, error: 'object not found in R2' }
}
const supabase = await createServerClient()
const { data, error } = await supabase
.from('files')
.insert({
tenant_id: session.tenantId,
uploader_id: session.userId,
r2_key: parsed.data.key,
content_type: head.ContentType ?? 'application/octet-stream',
size_bytes: head.ContentLength ?? parsed.data.size,
status: 'ready',
})
.select('id')
.single()
if (error) return { ok: false, error: error.message }
return { ok: true, id: data.id }
}
5. What this buys you
The server never sees the bytes, so the upload scales independently of your function execution budget. The R2 prefix encodes the tenant, so a malicious client cannot upload into another tenant's namespace even with a leaked signed URL. The canonical metadata row writes only after the upload succeeds, so orphaned files in the bucket are a non-issue. The bill drops to storage and request cost; egress is zero whether you serve the file once or a million times.
That is the cost line the R2 marketing leads with, configured into a real production flow instead of left as a screenshot in a dashboard tour.