Cloudflare R2 Storage
S3-compatible object storage for file uploads, chat attachments, and session recordings
Cloudflare R2 Storage
SavvySolve uses Cloudflare R2 for storing user-uploaded files, chat attachments, and session recordings. R2 provides S3-compatible object storage with zero egress fees, making it cost-effective for a platform where customers and solvers frequently share files.
Why R2?
Cloudflare R2 was chosen for several reasons:
- S3 compatibility: Works with the standard AWS SDK, no proprietary client needed
- Zero egress fees: Unlike S3, downloading files from R2 is free
- Global CDN integration: Files served through Cloudflare's edge network
- Cost-effective: Pay only for storage and operations, not bandwidth
Architecture
The file upload flow uses presigned URLs to enable secure, direct browser-to-R2 uploads:
┌─────────┐ 1. Request upload URL ┌─────────────┐
│ Browser │ ─────────────────────────────→ │ /api/upload │
│ │ ←───────────────────────────── │ (Next.js) │
│ │ 2. Presigned URL + key └─────────────┘
│ │
│ │ 3. PUT file directly ┌─────────────┐
│ │ ─────────────────────────────→ │ Cloudflare │
│ │ ←───────────────────────────── │ R2 │
│ │ 4. 200 OK └─────────────┘
└─────────┘This pattern keeps large files off the application server, reducing latency and server load.
Configuration
Environment Variables
# Cloudflare account ID (found in dashboard URL)
CLOUDFLARE_ACCOUNT_ID=your_account_id
# R2 API credentials (create in R2 > Manage R2 API Tokens)
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
# Bucket name
R2_CHAT_ATTACHMENTS_BUCKET=savvysolve-chat-attachments
# Public URL for serving files (via Cloudflare CDN)
R2_PUBLIC_URL=https://cdn.savvysolve.ioBucket Setup
- Create a bucket in the Cloudflare dashboard named
savvysolve-chat-attachments - Configure CORS for browser uploads:
[
{
"AllowedOrigins": ["https://savvysolve.io", "http://localhost:3000"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]- (Optional) Connect a custom domain for public access via CDN
Client Implementation
The R2 client is a singleton that initializes on first use:
import { S3Client } from "@aws-sdk/client-s3";
let r2Client: S3Client | null = null;
export function getR2Client(): S3Client {
if (!r2Client) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
if (!accountId || !accessKeyId || !secretAccessKey) {
throw new Error("Missing R2 configuration");
}
r2Client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
}
return r2Client;
}Upload Utilities
Presigned URL Generation
The getUploadUrl function creates a presigned URL that allows direct browser uploads:
export async function getUploadUrl(
sessionId: string,
filename: string,
contentType: AllowedMimeType,
contentLength: number,
): Promise<{ uploadUrl: string; key: string; publicUrl: string }> {
// Validate content type and size
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
throw new Error(`Invalid content type: ${contentType}`);
}
if (contentLength > MAX_FILE_SIZE) {
throw new Error(`File too large. Maximum: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
}
const client = getR2Client();
const key = generateFileKey(sessionId, filename);
const command = new PutObjectCommand({
Bucket: CHAT_ATTACHMENTS_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: contentLength,
});
// URL valid for 10 minutes
const uploadUrl = await getSignedUrl(client, command, { expiresIn: 600 });
return {
uploadUrl,
key,
publicUrl: `${R2_PUBLIC_URL}/${key}`,
};
}Allowed File Types
For security, only specific MIME types are permitted:
| Type | MIME |
|---|---|
| JPEG | image/jpeg |
| PNG | image/png |
| GIF | image/gif |
| WebP | image/webp |
application/pdf |
Maximum file size: 10 MB
File Key Structure
Files are organized by session for easy cleanup and access control:
sessions/{sessionId}/{timestamp}-{random}-{sanitized-filename}
Example: sessions/abc-123/1704067200000-x7k2m9-screenshot.pngAPI Route
The /api/upload endpoint handles presigned URL requests:
export async function POST(request: Request) {
// Verify authentication
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate request
const body = await request.json();
const { sessionId, filename, contentType, contentLength } =
uploadRequestSchema.parse(body);
// Generate presigned URL
const { uploadUrl, key, publicUrl } = await getUploadUrl(
sessionId, filename, contentType, contentLength
);
return NextResponse.json({ uploadUrl, key, publicUrl });
}React Hook
The useFileUpload hook provides a complete upload interface for components:
import { useFileUpload } from "@/hooks/use-file-upload";
function ChatAttachment({ sessionId }: { sessionId: string }) {
const { upload, isUploading, progress, error } = useFileUpload({
sessionId,
onUploadComplete: (result) => {
console.log("Uploaded:", result.publicUrl);
},
onUploadError: (err) => {
console.error("Upload failed:", err.message);
},
});
const handleFileSelect = async (file: File) => {
try {
const result = await upload(file);
// result: { key, publicUrl, filename, contentType, size }
} catch (err) {
// Error already passed to onUploadError
}
};
return (
<div>
<input
type="file"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
disabled={isUploading}
/>
{isUploading && <progress value={progress} max={100} />}
{error && <p className="text-red-500">{error.message}</p>}
</div>
);
}Hook Return Values
| Property | Type | Description |
|---|---|---|
upload | (file: File) => Promise<UploadResult> | Upload a file |
isUploading | boolean | Upload in progress |
progress | number | Progress percentage (0-100) |
error | Error | null | Last upload error |
Server-Side Uploads
For server-side uploads (e.g., processing recordings), use uploadFile directly:
import { uploadFile } from "@/lib/r2";
const buffer = await processRecording(sessionId);
const { key, publicUrl } = await uploadFile(
sessionId,
"recording.webm",
"video/webm",
buffer
);File Deletion
To remove files from R2:
import { deleteFile } from "@/lib/r2";
await deleteFile("sessions/abc-123/1704067200000-x7k2m9-screenshot.png");Security Considerations
- Authentication: The
/api/uploadroute requires Clerk authentication - Validation: File type and size validated server-side before URL generation
- Presigned URLs: Short-lived (10 minutes) to limit exposure
- Session scoping: Files organized by session ID for access control
- Sanitized filenames: Special characters removed to prevent path traversal
File Structure
lib/r2/
├── index.ts # Barrel exports
├── client.ts # S3Client singleton
├── client.test.ts # Client tests
├── upload.ts # Upload utilities
└── upload.test.ts # Upload tests
app/api/upload/
├── route.ts # Upload API route
└── route.test.ts # Route tests
hooks/
├── use-file-upload.tsx # React upload hook
└── use-file-upload.test.tsx # Hook testsRelated Documentation
- Session Interface - Where file uploads are used in sessions
- Chat Interface - Chat attachment implementation