This module provides two composables for file storage:
| Composable | Description |
|---|---|
useConvexFileUpload | Upload files with progress tracking and cancel support |
useConvexStorageUrl | Get reactive URLs for stored files |
Create the required Convex functions:
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'
// Generate a short-lived upload URL
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl()
},
})
// Get file URL for display
export const getUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId)
},
})
// Delete a file from storage
export const deleteFile = mutation({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
await ctx.storage.delete(args.storageId)
},
})
Upload files with automatic progress tracking and cancel support.
const {
upload, // (file: File, args?) => Promise<string> - returns storageId
data, // Ref<string | undefined> - last successful storageId
status, // ComputedRef<'idle' | 'pending' | 'success' | 'error'>
pending, // ComputedRef<boolean>
progress, // Ref<number> - 0 to 100
error, // Ref<Error | null>
cancel, // () => void - abort upload and reset state
} = useConvexFileUpload(api.files.generateUploadUrl)
useConvexFileUpload(mutation, {
// Callbacks
onSuccess: (storageId, file) => { },
onError: (error, file) => { },
// Validation (checked before upload starts)
maxSize: 5 * 1024 * 1024, // 5MB max
allowedTypes: ['image/jpeg', 'image/png'], // Exact MIME types
// Or use wildcards:
// allowedTypes: ['image/*'], // Any image type
// allowedTypes: ['image/*', 'application/pdf'], // Any image or PDF
})
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const {
upload,
pending,
progress,
error,
data: storageId,
} = useConvexFileUpload(api.files.generateUploadUrl)
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const id = await upload(file)
console.log('Uploaded:', id)
} catch {
// Error is automatically tracked in `error` ref
}
input.value = ''
}
</script>
<template>
<div>
<input
type="file"
:disabled="pending"
@change="handleFileChange"
/>
<div v-if="pending">
Uploading: {{ progress }}%
</div>
<p v-if="error" class="error">
{{ error.message }}
</p>
<p v-if="storageId">
Uploaded: {{ storageId }}
</p>
</div>
</template>
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { upload, pending, progress, cancel } = useConvexFileUpload(
api.files.generateUploadUrl
)
</script>
<template>
<div v-if="pending" class="upload-progress">
<div class="progress-bar">
<div class="fill" :style="{ width: `${progress}%` }" />
</div>
<span>{{ progress }}%</span>
<button @click="cancel">Cancel</button>
</div>
</template>
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { upload } = useConvexFileUpload(
api.files.generateUploadUrl,
{
onSuccess: (storageId, file) => {
console.log(`Uploaded ${file.name}: ${storageId}`)
},
onError: (error, file) => {
console.error(`Failed to upload ${file.name}:`, error)
},
}
)
</script>
Pass arguments to the generateUploadUrl mutation (for auth, metadata, etc.):
// Backend mutation that accepts args
export const generateUploadUrl = mutation({
args: { type: v.string() },
handler: async (ctx, args) => {
// Can use args.type for validation, logging, etc.
return await ctx.storage.generateUploadUrl()
},
})
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { upload } = useConvexFileUpload(api.files.generateUploadUrl)
async function handleAvatarUpload(file: File) {
// Pass args to the mutation
await upload(file, { type: 'avatar' })
}
async function handleDocumentUpload(file: File) {
await upload(file, { type: 'document' })
}
</script>
After upload, save the storageId to your data model:
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { upload, pending, progress } = useConvexFileUpload(
api.files.generateUploadUrl
)
const { mutate: createPost } = useConvexMutation(api.posts.create)
async function handleSubmit(title: string, file: File) {
// Upload file first
const storageId = await upload(file)
// Then save document with storageId
await createPost({
title,
imageId: storageId,
})
}
</script>
Get a reactive URL for a file in storage. Automatically skips the query when storageId is null/undefined.
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const storageId = ref<string | null>(null)
// URL automatically updates when storageId changes
const imageUrl = useConvexStorageUrl(api.files.getUrl, storageId)
</script>
<template>
<img v-if="imageUrl" :src="imageUrl" alt="Uploaded file" />
</template>
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const {
upload,
pending,
progress,
data: storageId,
} = useConvexFileUpload(api.files.generateUploadUrl)
// URL updates automatically after successful upload
const imageUrl = useConvexStorageUrl(api.files.getUrl, storageId)
async function handleFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (file) await upload(file)
}
</script>
<template>
<input type="file" @change="handleFile" :disabled="pending" />
<div v-if="pending">
Uploading: {{ progress }}%
</div>
<img v-if="imageUrl" :src="imageUrl" />
</template>
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const props = defineProps<{ postId: string }>()
// Fetch document that contains a storageId
const { data: post } = await useConvexQuery(
api.posts.get,
computed(() => ({ id: props.postId }))
)
// Get URL from document's storageId
const imageUrl = useConvexStorageUrl(
api.files.getUrl,
computed(() => post.value?.imageId)
)
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<img v-if="imageUrl" :src="imageUrl" :alt="post.title" />
</article>
</template>
A full file upload component with progress, cancel, validation, and display:
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const {
upload,
pending,
progress,
status,
error,
data: storageId,
cancel,
} = useConvexFileUpload(api.files.generateUploadUrl, {
// Built-in validation
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
})
const imageUrl = useConvexStorageUrl(api.files.getUrl, storageId)
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
await upload(file)
} catch {
// Error tracked automatically in error ref
}
input.value = ''
}
</script>
<template>
<div class="uploader">
<!-- Upload area -->
<div class="upload-area" :class="{ uploading: pending }">
<input
id="file"
type="file"
accept="image/*"
:disabled="pending"
@change="handleFileChange"
/>
<label for="file">
{{ pending ? 'Uploading...' : 'Choose an image' }}
</label>
<!-- Progress -->
<div v-if="pending" class="progress">
<div class="bar">
<div class="fill" :style="{ width: `${progress}%` }" />
</div>
<span>{{ progress }}%</span>
<button type="button" @click="cancel">Cancel</button>
</div>
</div>
<!-- Status -->
<p class="status">
Status: <code>{{ status }}</code>
</p>
<!-- Error -->
<p v-if="error" class="error">
{{ error.message }}
</p>
<!-- Preview -->
<div v-if="imageUrl" class="preview">
<img :src="imageUrl" alt="Uploaded" />
<code>{{ storageId }}</code>
</div>
</div>
</template>
<style scoped>
.upload-area {
border: 2px dashed #ccc;
padding: 2rem;
text-align: center;
border-radius: 8px;
}
.upload-area.uploading {
border-color: #3b82f6;
background: #eff6ff;
}
.upload-area input[type="file"] {
display: none;
}
.upload-area label {
cursor: pointer;
color: #666;
}
.progress {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.fill {
height: 100%;
background: #3b82f6;
transition: width 0.1s;
}
.preview img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
}
.error {
color: #dc2626;
}
</style>
Use built-in validation to reject files before upload starts:
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { upload, error } = useConvexFileUpload(
api.files.generateUploadUrl,
{
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['image/*'], // Any image type (wildcard)
onError: (err) => {
// Show toast, etc.
console.error(err.message)
},
}
)
</script>
<template>
<input type="file" @change="handleFile" />
<p v-if="error" class="error">{{ error.message }}</p>
</template>
Wildcard patterns:
image/* - Any image (jpeg, png, gif, webp, etc.)video/* - Any videoaudio/* - Any audioapplication/* - Any application typeYou can mix wildcards with exact types: ['image/*', 'application/pdf']
Validation errors are tracked in the error ref just like upload errors.
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { mutate: deleteFile, pending } = useConvexMutation(api.files.deleteFile)
async function handleDelete(storageId: string) {
if (confirm('Delete this file?')) {
await deleteFile({ storageId })
}
}
</script>
<template>
<button @click="handleDelete(storageId)" :disabled="pending">
Delete
</button>
</template>