La mayoría de guías "R2 quickstart" te muestra el dashboard y se queda ahí. R2 en producción es el pipeline de upload de abajo: el client nunca recibe la secret key, el archivo va directo a R2 sin pasar por proxy por tu servidor, y la fila canónica de metadatos se escribe solo después de que el upload completa. Tres archivos; sin costes sorpresa, sin credenciales filtradas, sin archivos huérfanos en el bucket.
1. URL de upload pre-firmada
Una Server Action acuña una URL PUT pre-firmada con TTL corto. El client sube directamente a R2 usando esa URL; el servidor nunca ve los 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. El client R2
El AWS SDK apunta a R2 con un cambio de config: el endpoint. Todo lo demás se mantiene estándar, así que tooling AWS y CLIs existentes funcionan sin modificación.
// 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. El client sube directamente a R2
El navegador usa la URL pre-firmada con una sola petición PUT. El evento de progress alimenta la UI; sin round-trip al servidor por cada 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. El paso de confirmación escribe la fila de metadatos
El client le dice al servidor "el upload tuvo éxito para esta clave". El servidor verifica que el objeto exista en R2 (petición HEAD), y luego escribe la fila canónica files.
// 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' }
// Ata la clave al tenant autenticado; el prefijo codifica el tenant.
if (!parsed.data.key.startsWith(`${session.tenantId}/`)) {
return { ok: false, error: 'invalid key' }
}
// Verifica que el objeto haya aterrizado de verdad en 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. Qué te compra esto
El servidor nunca ve los bytes, así que el upload escala independientemente de tu presupuesto de ejecución de funciones. El prefijo R2 codifica el tenant, así que un client malicioso no puede subir al namespace de otro tenant ni siquiera con una URL firmada filtrada. La fila canónica de metadatos se escribe solo después de que el upload tiene éxito, así que los archivos huérfanos en el bucket no son un problema. La factura cae a coste de storage y coste por petición; el egress es cero tanto si sirves el archivo una vez como un millón.
Esa es la línea de coste con la que el marketing de R2 abre la presentación, configurada en un flujo real de producción en vez de dejada como pantallazo en un tour del dashboard.