Skip to Content
Iris Saas Kit documentation is under construction.
TechnicalConvex File Storage

Convex File Storage

Complete guide to file storage implementation using Convex’s built-in file storage system.

🏗️ File Storage Architecture

Schema Design

File references are stored as optional StorageId fields in your database schema:

// convex/schema/core.ts const coreSchema = { users: defineTable({ email: v.string(), name: v.string(), picture: v.optional(v.id('_storage')), // Profile picture isDeveloper: v.optional(v.boolean()), }).index('email', ['email']), workspaces: defineTable({ name: v.string(), slug: v.string(), logo: v.optional(v.id('_storage')), // Workspace logo createdBy: v.optional(v.id('users')), isArchived: v.boolean(), }) .index('slug', ['slug', 'isArchived']) .searchIndex('name', { searchField: 'name' }), workspaceMembers: defineTable({ workspaceSlug: v.string(), userId: v.id('users'), picture: v.optional(v.id('_storage')), // Override user picture email: v.string(), name: v.string(), role: workspaceMemberRoleValidator, }), };

Key Design Principles:

  • Files are optional by default (v.optional(v.id("_storage")))
  • No separate files table - references stored directly on entities
  • Automatic cleanup when updating file references
  • Multiple file fields per entity (user picture, workspace logo)

Utility Functions

Basic file operations in convex/utility.ts:

// convex/utility.ts import { v } from 'convex/values'; import { query, mutation } from './_generated/server'; // Generate upload URL (public endpoint) export const generateUploadUrl = mutation(async ctx => { return await ctx.storage.generateUploadUrl(); }); // Get file URL for display export const getImageUrl = query({ args: { storageId: v.id('_storage') }, handler: async (ctx, { storageId }) => { return await ctx.storage.getUrl(storageId); }, });

Authentication-aware uploads in domain-specific files:

// convex/core/users.mutations.ts export const generateUploadUrl = userMutation({ args: {}, handler: async ctx => { return await ctx.storage.generateUploadUrl(); }, });

📤 Frontend Upload Implementation

Custom Upload Hook

Centralized file upload logic with toast notifications:

// hooks/useUploadFiles.tsx import axios from 'axios'; import { useMutation } from 'convex/react'; import { useState } from 'react'; import toast from 'react-hot-toast'; import { api } from '@/convex/_generated/api'; import { Id } from '@/convex/_generated/dataModel'; type Options = { includeToast?: boolean; }; const useUploadFiles = (options?: Options) => { const [isLoading, setIsLoading] = useState(false); const generateUploadUrl = useMutation(api.utility.generateUploadUrl); const uploadFiles = async (files: File[]) => { const uploadPromises = files.map(async file => { const uploadUrl = await generateUploadUrl(); const { data } = await axios.post<{ storageId: Id<'_storage'> }>( uploadUrl, file, { headers: { 'Content-Type': file.type } } ); return { storageId: data.storageId, name: file.name }; }); setIsLoading(true); if (options?.includeToast) { toast.loading('Uploading files...', { id: 'upload-files' }); } const results = await Promise.all(uploadPromises); setIsLoading(false); if (options?.includeToast) { toast.success('Files uploaded successfully!', { id: 'upload-files' }); } return results; }; return { uploadFiles, isLoading }; };

Key Features:

  • Parallel uploads for multiple files
  • Built-in loading states and error handling
  • Optional toast notifications
  • Type-safe StorageId return values
  • Reusable across different components

Profile Picture Upload Pattern

Complete user profile update flow with file management:

// _hooks/useCurrentUser.update.tsx export default function useUpdateCurrentUser() { const updateProfile = useMutation(api.core.users.updateProfile); const generateUploadUrl = useMutation(api.core.users.generateUploadUrl); const updatePicture = async (file: File) => { setIsUpdating(true); setError(null); try { // 1. Generate upload URL const uploadUrl = await generateUploadUrl({}); // 2. Upload file const uploadResult = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': file.type }, body: file, }); if (!uploadResult.ok) { throw new Error('Failed to upload file'); } const { storageId } = await uploadResult.json(); // 3. Update user profile (automatic old file cleanup) await updateProfile({ picture: storageId as Id<'_storage'> }); return { success: true, storageId }; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to update picture'; setError(errorMessage); return { success: false, error: errorMessage }; } finally { setIsUpdating(false); } }; return { updatePicture, isUpdating, error }; }

🔧 Backend File Management

Profile Updates with Cleanup

Automatic old file deletion when updating profile pictures:

// convex/core/users.mutations.ts export const updateProfile = userMutation({ args: { name: v.optional(v.string()), picture: v.optional(v.id('_storage')), }, handler: async (ctx, args) => { const updateData: Partial<{ name: string; picture: Id<'_storage'>; }> = {}; // Handle picture update with cleanup if (args.picture !== undefined) { // Get current user to check existing picture const currentUser = await ctx.db.get(ctx.currentUser._id); // Delete old picture if it exists and is different if (currentUser?.picture && currentUser.picture !== args.picture) { try { await ctx.storage.delete(currentUser.picture); } catch (error) { // Log error but don't fail the update console.error('Failed to delete old profile picture:', error); } } updateData.picture = args.picture; } if (args.name !== undefined) { updateData.name = args.name; } if (Object.keys(updateData).length === 0) { throw new Error('At least one field must be provided for update'); } await ctx.db.patch(ctx.currentUser._id, updateData); return ctx.currentUser._id; }, });

File URL Generation in Queries

Include file URLs when querying entities with files:

// convex/core/users.queries.ts export const getCurrentUser = userQuery({ args: { includeDetail: v.optional(v.array(v.literal('picture'))), }, handler: async (ctx, args) => { const currentUser = ctx.currentUser; return { ...currentUser, profileUrl: args.includeDetail?.includes('picture') && currentUser.picture ? await ctx.storage.getUrl(currentUser.picture) : null, }; }, });

Workspace logos with member data:

// convex/core/workspaces.queries.ts export const getUserWorkspaces = authedQuery({ args: {}, handler: async ctx => { const userId = ctx.currentUserId; const workspaceMembers = await ctx.db .query('workspaceMembers') .withIndex('userId', q => q.eq('userId', userId)) .collect(); const workspacePromises = workspaceMembers.map(async member => { const workspace = await ctx.db .query('workspaces') .withIndex('slug', q => q.eq('slug', member.workspaceSlug).eq('isArchived', false) ) .unique(); return { ...workspace, logoUrl: workspace?.logo ? await ctx.storage.getUrl(workspace.logo) : null, member, }; }); return await Promise.all(workspacePromises); }, });

🎨 Frontend Display & Upload UI

Workspace Settings Implementation

Real-world file upload UI with preview and validation:

// Workspace settings page const GeneralSettingsPage = () => { const { workspace } = useCurrentWorkspace(); const updateWorkspace = useMutation(api.core.workspaces.updateWorkspaceMutation); const { uploadFiles, isLoading: isUploading } = useUploadFiles(); const [logoUrl, setLogoUrl] = useState<string | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null); // Update UI when workspace loads React.useEffect(() => { if (workspace) { setLogoUrl(workspace.logoUrl || null); } }, [workspace]); // Handle file selection with preview const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] || null; setLogoFile(file); if (file) { setLogoUrl(URL.createObjectURL(file)); // Preview } }; // Handle form submission with file upload const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!workspace) return; setIsSaving(true); let logoStorageId = undefined; try { // Upload new logo if selected if (logoFile) { const uploaded = await uploadFiles([logoFile]); logoStorageId = uploaded[0].storageId; } // Update workspace with new logo await updateWorkspace({ workspaceSlug: workspace.slug, name: name.trim(), slug: slug.trim(), logoStorageId, }); setSuccess(true); } catch (err: any) { setFormError(err?.message || "Failed to update workspace"); } finally { setIsSaving(false); } }; return ( <form onSubmit={handleSubmit}> <div className="space-y-2"> <Label htmlFor="logo">Workspace Logo</Label> <div className="flex items-center gap-4"> {/* Logo preview */} {logoUrl ? ( <img src={logoUrl} alt="Workspace Logo" className="w-16 h-16 rounded-full object-cover border" /> ) : ( <div className="w-16 aspect-square rounded-full bg-muted flex items-center justify-center"> <ImageIcon className="h-6 w-6 text-muted-foreground" /> </div> )} {/* File input */} <Input id="logo" type="file" accept="image/*" onChange={handleLogoChange} disabled={isSaving || isUploading} /> </div> </div> <Button type="submit" disabled={isSaving || isUploading}> {isSaving || isUploading ? "Saving..." : "Save Changes"} </Button> </form> ); };

Next.js Image Configuration

Allow Convex file URLs in Next.js image optimization:

// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'judicious-aardvark-200.convex.cloud', // Your Convex deployment }, ], }, }; export default nextConfig;

Usage with Next.js Image component:

import Image from 'next/image'; // In your components { user.profileUrl && ( <Image src={user.profileUrl} alt='Profile' width={40} height={40} className='rounded-full' /> ); }

🔒 Best Practices

File Security & Validation

  1. Authentication: Use userMutation for upload URL generation
  2. File type validation: Check file types on upload
  3. Size limits: Implement client and server-side size checks
  4. Cleanup: Always delete old files when updating

Performance Optimization

  1. Lazy loading: Only load file URLs when needed (includeDetail pattern)
  2. Parallel uploads: Use Promise.all() for multiple files
  3. Caching: File URLs are stable and can be cached
  4. Image optimization: Use Next.js Image component for automatic optimization

Error Handling

  1. Graceful degradation: Don’t fail updates if file deletion fails
  2. User feedback: Provide loading states and error messages
  3. Validation: Check file existence before operations
  4. Cleanup on errors: Remove uploaded files if database operations fail

🚀 Common Patterns

Avatar/Profile Pictures

  • Store as picture: v.optional(v.id("_storage"))
  • Include cleanup logic in update mutations
  • Use conditional URL generation in queries

Workspace/Organization Assets

  • Store logos, banners, documents
  • Support file preview before upload
  • Validate file types and sizes

File Metadata

  • Store original filename, file type, size if needed
  • Use helper functions for common operations
  • Implement soft delete patterns for important files
Last updated on