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
- Authentication: Use
userMutation
for upload URL generation - File type validation: Check file types on upload
- Size limits: Implement client and server-side size checks
- Cleanup: Always delete old files when updating
Performance Optimization
- Lazy loading: Only load file URLs when needed (
includeDetail
pattern) - Parallel uploads: Use
Promise.all()
for multiple files - Caching: File URLs are stable and can be cached
- Image optimization: Use Next.js Image component for automatic optimization
Error Handling
- Graceful degradation: Don’t fail updates if file deletion fails
- User feedback: Provide loading states and error messages
- Validation: Check file existence before operations
- 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