SavvySolve Docs

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

.env.local
# 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.io

Bucket Setup

  1. Create a bucket in the Cloudflare dashboard named savvysolve-chat-attachments
  2. Configure CORS for browser uploads:
CORS Configuration
[
  {
    "AllowedOrigins": ["https://savvysolve.io", "http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]
  1. (Optional) Connect a custom domain for public access via CDN

Client Implementation

The R2 client is a singleton that initializes on first use:

lib/r2/client.ts
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:

lib/r2/upload.ts
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:

TypeMIME
JPEGimage/jpeg
PNGimage/png
GIFimage/gif
WebPimage/webp
PDFapplication/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.png

API Route

The /api/upload endpoint handles presigned URL requests:

app/api/upload/route.ts
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:

hooks/use-file-upload.tsx
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

PropertyTypeDescription
upload(file: File) => Promise<UploadResult>Upload a file
isUploadingbooleanUpload in progress
progressnumberProgress percentage (0-100)
errorError | nullLast 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

  1. Authentication: The /api/upload route requires Clerk authentication
  2. Validation: File type and size validated server-side before URL generation
  3. Presigned URLs: Short-lived (10 minutes) to limit exposure
  4. Session scoping: Files organized by session ID for access control
  5. 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 tests

On this page