Skip to main content
Evaly uses Cloudflare R2 for secure, scalable file storage. The system supports images, audio, and video files for rich question content with automatic CDN delivery and size validation.

Supported File Types

Images

Allowed formats: JPG, JPEG, PNG, GIF, WebP Maximum size: 10 MB Use cases:
  • Diagrams and charts in math/science questions
  • Visual identification questions
  • Reference images for essay prompts

Audio

Allowed formats: MP3, WAV, OGG, M4A, WebM Maximum size: 50 MB Use cases:
  • Language listening comprehension tests
  • Music theory questions
  • Pronunciation examples

Video

Allowed formats: MP4, WebM, MOV Maximum size: 100 MB Use cases:
  • Video-based question prompts
  • Instructional content
  • Scenario-based assessments
All formats are optimized for Cloudflare Image Optimization and streaming delivery.

Upload Flow

Evaly uses a secure two-step upload process:
1

Generate Upload URL

Request a pre-signed upload URL from the server:
const { uploadUrl, key, publicUrl } = await generateMediaUploadUrl({
  filename: "diagram.png",
  mediaType: "image",
  fileSize: 2048576  // 2 MB in bytes
});
Server-side validation:
  • User authentication
  • Organization membership
  • File extension validation
  • File size validation
  • Unique key generation
2

Upload to R2

Upload the file directly to Cloudflare R2 using the pre-signed URL:
const response = await fetch(uploadUrl, {
  method: "PUT",
  body: file,
  headers: {
    "Content-Type": file.type
  }
});

if (!response.ok) {
  throw new Error("Upload failed");
}
3

Use Public URL

Store the public URL in your question data:
// Public URL format: {R2_CDN_URL}/{key}
// Example: https://cdn.evaly.io/editor-media/org_123/image/abc-def.png

await updateQuestion({
  questionId,
  content: `<img src="${publicUrl}" alt="Diagram" />`
});

File Organization

Files are organized by organization and media type:
editor-media/
  {organizationId}/
    image/
      {uuid}.png
      {uuid}.jpg
    audio/
      {uuid}.mp3
      {uuid}.wav
    video/
      {uuid}.mp4
      {uuid}.webm
Benefits:
  • Clear namespace separation per organization
  • Easy to identify media type
  • Collision-free with UUID filenames
  • Simple to implement bulk deletion per organization

Storage Configuration

Environment Variables

Configure R2 in your Convex environment:
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET=evaly-media
R2_ENDPOINT=https://account.r2.cloudflarestorage.com
R2_CDN_URL=https://cdn.evaly.io
R2_TOKEN=your_r2_token
Security: Never expose these credentials in client-side code. All R2 operations must go through Convex server functions.

R2 Client Initialization

// convex/common/storage.ts
import { R2 } from "@convex-dev/r2";
import { components } from "../_generated/api";

export const r2 = new R2(components.r2);

export const { generateUploadUrl, syncMetadata } = r2.clientApi({
  checkUpload: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) {
      throw new ConvexError({ message: "Not authenticated" });
    }
  }
});

Upload Validation

File Extension Validation

const ALLOWED_IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"];
const ALLOWED_AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "webm"];
const ALLOWED_VIDEO_EXTENSIONS = ["mp4", "webm", "mov"];

const extension = filename.split(".").pop()?.toLowerCase() || "";

if (!allowedExtensions.includes(extension)) {
  throw new ConvexError({
    message: `Invalid file type. Allowed ${mediaType} formats: ${allowedExtensions.join(", ")}`
  });
}

File Size Validation

const MAX_IMAGE_SIZE = 10 * 1024 * 1024;   // 10 MB
const MAX_AUDIO_SIZE = 50 * 1024 * 1024;   // 50 MB
const MAX_VIDEO_SIZE = 100 * 1024 * 1024;  // 100 MB

if (fileSize > maxSize) {
  const maxSizeMB = maxSize / (1024 * 1024);
  throw new ConvexError({
    message: `File too large. Maximum ${mediaType} size is ${maxSizeMB}MB`
  });
}
Validation happens before upload URL generation, preventing unnecessary upload bandwidth.

URL Conversion Utilities

Key to Public URL

Convert R2 key to CDN URL:
import { keyToPublicUrl } from "@/convex/common/storage";

const key = "editor-media/org_123/image/abc-def.png";
const publicUrl = keyToPublicUrl(key);
// Returns: "https://cdn.evaly.io/editor-media/org_123/image/abc-def.png"
Features:
  • Handles already-converted URLs (idempotent)
  • Uses environment variable for CDN URL
  • Strips trailing slashes automatically

Public URL to Key

Extract R2 key from public URL (for deletion):
import { publicUrlToKey } from "@/convex/common/storage";

const url = "https://cdn.evaly.io/editor-media/org_123/image/abc-def.png";
const key = publicUrlToKey(url);
// Returns: "editor-media/org_123/image/abc-def.png"
Use cases:
  • Deleting files when questions are removed
  • Migrating to new storage backend
  • Cleanup operations

File Deletion

Delete files from R2 storage:
import { r2, publicUrlToKey } from "@/convex/common/storage";

// From public URL
const key = publicUrlToKey(publicUrl);
await r2.deleteObject(key);

// Direct key
await r2.deleteObject("editor-media/org_123/image/abc-def.png");
export const deleteQuestion = mutation({
  args: { questionId: v.id("question") },
  handler: async (ctx, args) => {
    const question = await ctx.db.get(args.questionId);
    if (!question) return;
    
    // Extract image URLs from question content
    const imageUrls = extractImageUrls(question.content);
    
    // Delete all associated files
    for (const url of imageUrls) {
      const key = publicUrlToKey(url);
      try {
        await r2.deleteObject(key);
      } catch (error) {
        console.warn(`Failed to delete file ${key}:`, error);
      }
    }
    
    // Delete the question
    await ctx.db.patch(args.questionId, {
      deletedAt: Date.now()
    });
  }
});

CDN Integration

Cloudflare R2 is integrated with Cloudflare CDN for fast global delivery: Benefits:
  • Automatic caching at edge locations
  • Image optimization and resizing
  • Reduced bandwidth costs
  • Faster load times for participants
CDN URL Structure:
https://cdn.evaly.io/{key}
Configure your R2 bucket with a custom domain in the Cloudflare dashboard to use the CDN.

Frontend Integration

React Upload Component Example

import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";

function ImageUploader() {
  const generateUrl = useMutation(api.organizer.editorMedia.generateMediaUploadUrl);
  
  const handleUpload = async (file: File) => {
    // Step 1: Generate upload URL
    const { uploadUrl, publicUrl } = await generateUrl({
      filename: file.name,
      mediaType: "image",
      fileSize: file.size
    });
    
    // Step 2: Upload to R2
    const response = await fetch(uploadUrl, {
      method: "PUT",
      body: file
    });
    
    if (!response.ok) {
      throw new Error("Upload failed");
    }
    
    // Step 3: Use publicUrl in your content
    return publicUrl;
  };
  
  return (
    <input 
      type="file" 
      accept="image/*" 
      onChange={(e) => {
        const file = e.target.files?.[0];
        if (file) handleUpload(file);
      }}
    />
  );
}

TipTap Editor Integration

import { useEditor } from "@tiptap/react";
import Image from "@tiptap/extension-image";

const editor = useEditor({
  extensions: [
    Image.configure({
      allowBase64: false,
      HTMLAttributes: {
        class: "max-w-full h-auto"
      }
    })
  ],
  editorProps: {
    handleDrop: async (view, event, slice, moved) => {
      const file = event.dataTransfer?.files[0];
      if (file?.type.startsWith("image/")) {
        event.preventDefault();
        
        const publicUrl = await handleUpload(file);
        
        const { schema } = view.state;
        const node = schema.nodes.image.create({ src: publicUrl });
        const transaction = view.state.tr.replaceSelectionWith(node);
        view.dispatch(transaction);
        
        return true;
      }
      return false;
    }
  }
});

Error Handling

Common Upload Errors

{
  message: "Not authenticated"
}
Solution: Ensure user is logged in before attempting upload.
{
  message: "Invalid file type. Allowed image formats: jpg, jpeg, png, gif, webp"
}
Solution: Check file extension before calling generateMediaUploadUrl.
{
  message: "File too large. Maximum image size is 10MB"
}
Solution: Compress or resize file before upload.
{
  message: "Organization not found"
}
Solution: Ensure user has selected an organization.

Best Practices

  1. Always validate file size client-side before requesting upload URL to save bandwidth
  2. Show upload progress to users for better UX (use XMLHttpRequest instead of fetch)
  3. Compress images before upload to reduce storage costs
  4. Delete orphaned files when questions are deleted to prevent storage bloat
  5. Use lazy loading for images in question lists to improve performance
  6. Handle upload failures gracefully with retry logic
  7. Cache CDN URLs in the browser for faster repeat access
  8. Use appropriate formats: WebP for images, MP4 for video, MP3 for audio

Storage Limits by Plan

Storage quotas (if implemented):
  • Free: 1 GB total storage
  • Pro: 50 GB total storage
  • Enterprise: Unlimited storage
Contact your plan administrator for current limits.

Migrating Existing Files

If migrating from another storage provider:
1

Export File List

Get all file URLs from your current provider.
2

Download Files

Download files to local storage or temporary S3 bucket.
3

Upload to R2

Use the standard upload flow to upload files to R2:
for (const file of files) {
  const { uploadUrl, publicUrl } = await generateMediaUploadUrl({
    filename: file.name,
    mediaType: detectMediaType(file.name),
    fileSize: file.size
  });
  
  await uploadToR2(uploadUrl, file.data);
  
  // Update database with new URL
  await updateQuestionImageUrl(file.questionId, publicUrl);
}
4

Verify Migration

Check that all questions display images correctly.
5

Clean Up

Delete files from old storage provider.

Build docs developers (and LLMs) love