Upload files
Upload one file to Convex storage with progress, cancellation, and reactive state.
An upload has up to three steps. useConvexFileUpload runs them in order, for the signed-in user who started the upload:
- Prepare: a mutation returns an upload URL.
- Upload: the browser sends the bytes to Convex storage and receives a storage ID.
- Complete (optional): your mutation or action records the file, for example on a document.
Backend
import { mutation } from './_generated/server'
import { auth } from './auth'
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
await auth.requireUser(ctx)
return await ctx.storage.generateUploadUrl()
},
})Component
<script setup lang="ts">
import { isConvexCallError } from '@lupinum/better-convex-nuxt/errors'
import { api } from '#convex/api'
const {
upload,
status,
pending,
progress,
error,
data: uploaded,
cancel,
reset,
} = useConvexFileUpload(api.files.generateUploadUrl, {
maxSize: 5 * 1024 * 1024,
allowedTypes: ['image/*', 'application/pdf'],
})
async function chooseFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
await upload(file)
} catch (failure) {
if (isConvexCallError(failure, 'CANCELLED')) return
// `error` shows every other failure.
}
}
</script>
<template>
<input type="file" :disabled="pending" @change="chooseFile" />
<progress v-if="pending" :value="progress.percent" max="100" />
<button v-if="pending" @click="cancel">Cancel</button>
<p v-if="error?.code === 'FILE_TOO_LARGE'">Choose a file smaller than 5 MB.</p>
<p v-else-if="error">{{ error.message }}</p>
<p v-if="uploaded">Uploaded as {{ uploaded.storageId }}</p>
<button v-if="status !== 'idle' && !pending" @click="reset">Clear</button>
</template>In plain Vue, import useConvexFileUpload from @lupinum/better-convex-vue. It has the same contract.
State
upload(file) resolves with { storageId, prepared, completed }, and data holds the same object after a successful upload:
storageIdis the branded Convex storage ID (Id<'_storage'>).preparedis the result of the prepare mutation.completedis the result ofcomplete, orundefinedwithout it.
status is idle, pending, success, or error. pending stays true through all three steps. progress exposes loaded, total, and percent of the upload step. error is undefined until an upload fails. A new upload clears data from the previous one.
One composable instance accepts one active upload. A second call while pending rejects with code UPLOAD_IN_PROGRESS instead of interleaving shared progress state.
Cancel and reset
cancel()stops the upload in flight, and the state returns toidle. A running storage upload is aborted, and a step that was not sent is never sent.upload()then rejects with codeCANCELLED. A Convex call that was already sent still finishes. If that call was the last call ofcomplete,upload()resolves with the result, butdatastays empty. When no upload is in flight,cancel()does nothing and keeps a finished result.reset()stops any upload in flight in the same way and clearsdata,error, andprogress.- Unmounting the owning component stops the upload in flight in the same way.
- A change of the signed-in user stops the upload with
IDENTITY_CHANGED, clears the state, and sends no later step.
Cancellation and identity changes return the state to idle, not error.
Upload errors
upload() rejects with a ConvexCallError. Two fields tell you where the upload stopped:
error.phaseis the step that failed:prepare,upload, orcomplete. Every earlier step succeeded. Checks that run before any request, such asFILE_TOO_LARGE, have nophase.error.outcometells whether the failed phase sent a request:not-sentorunknown. It isundefinedwhen Convex or the storage endpoint answered with a failure. See Error types.
error.functionName names the Convex function that failed: the prepare mutation, or the function that complete called.
| Code | When |
|---|---|
FILE_TOO_LARGE | The file is larger than maxSize; no request is sent |
FILE_TYPE_NOT_ALLOWED | The MIME type does not match allowedTypes; no request is sent |
UPLOAD_IN_PROGRESS | Another upload of the same composable is pending |
CANCELLED | cancel(), reset(), or unmounting stopped the upload |
IDENTITY_CHANGED | The signed-in user changed during the upload |
CLIENT_UNAVAILABLE | No browser Convex client exists, for example during server rendering |
INVALID_UPLOAD_URL | The prepare mutation, or the url option, did not give a URL |
Other failures are the error of your Convex function or of the storage request. Error messages never contain the upload URL.
Validation boundary
maxSize and allowedTypes provide immediate browser feedback. op.upload(url, file, { maxSize, allowedTypes }) in useConvexOperation runs the same check with the same codes. File name and MIME type are client-controlled. Validate product policy again before publishing or processing the file.
Complete the upload
The upload step only creates a storage object. Record it in a document with complete. It runs after the upload, as part of the same upload:
import type { UploadCompleteContext } from '@lupinum/better-convex-nuxt'
import type { Id } from '~~/convex/_generated/dataModel'
const props = defineProps<{ documentId: Id<'documents'> }>()
const { upload, pending, error } = useConvexFileUpload(api.files.generateUploadUrl, {
complete: (
op,
{ storageId, file, context: documentId }: UploadCompleteContext<string, Id<'documents'>>,
) =>
op.mutation(api.documents.attachFile, {
documentId,
storageId,
filename: file.name,
}),
})
const { completed: attachmentId } = await upload(file, {}, { context: props.documentId })complete receives an operation and { prepared, storageId, file, context }. Send each Convex call through op.mutation or op.action, so that it runs for the same user. It can send several calls. Its return value becomes completed. See Multi-step operations.
Pass the target with the upload
upload(file, args, { context }) takes the record the file belongs to as a third argument, after the prepare mutation's arguments, like Convex's own mutation(ref, args, options). complete and url receive it as context. Pass {} or undefined as args when the prepare mutation takes none.
Take the target from context, not from component state that complete reads later. The upload can take seconds; if the user selects another document or props change meanwhile, a complete that reads props.documentId attaches the file to the wrong record. context is captured when upload() is called: plain objects and arrays, including reactive() state, are copied, so later changes do not reach the upload. Pass values such as IDs, not refs.
The context type comes from the annotation on complete (or url), here UploadCompleteContext<string, Id<'documents'>>: the first type argument is the prepare result, the second the context. Once a context type is declared, upload() requires { context } of that type. Declare Id<'documents'> | undefined to keep it optional.
The same pattern carries a value that only exists at call time, such as an ID that the form's submit handler has just created:
const { mutate: subscribe } = useConvexMutation(api.subscriptions.create)
const { upload } = useConvexFileUpload(api.files.generateUploadUrl, {
complete: (
op,
{ storageId, context: subscriptionId }: UploadCompleteContext<string, Id<'subscriptions'>>,
) => op.mutation(api.subscriptions.attachDocument, { subscriptionId, storageId }),
})
async function submit(email: string, file: File) {
const subscriptionId = await subscribe({ email })
await upload(file, {}, { context: subscriptionId })
}When the signed-in user changes after the file was stored but before complete sent its first call, upload() rejects with IDENTITY_CHANGED, phase: 'complete', and outcome: 'not-sent'. The stored file then exists, but no document refers to it. The outcome covers every call of complete: once one call was sent, a later failure has outcome: 'unknown', because the earlier call may already refer to the file. Delete such files on the server, for example with a scheduled function that removes storage objects that no document refers to after a time limit.
Prepare an upload session
The prepare mutation can return an object instead of a URL, for example an upload session with a token. Select the URL with url. It is required when the result is not a string:
const { upload } = useConvexFileUpload(api.assets.createUploadSession, {
url: (session) => session.uploadUrl,
complete: (op, { prepared: session, storageId }) =>
op.mutation(api.assets.claimUploadSession, {
sessionId: session.sessionId,
token: session.token,
storageId,
}),
})url also receives { file, context } as its second argument, so the upload URL can depend on the per-call context.
Keep the session rules in Convex: who may create a session, when it expires, and that a session can be claimed once.
Multiple files are an application workflow
The library keeps the universal one-file primitive small. If a product needs multiple files, keep the item list, upload order, retry UI, and saved state in application code.
This example creates three upload workers during setup and records durable results separately from each worker's transient progress:
import type { Id } from '~~/convex/_generated/dataModel'
const workers = [
useConvexFileUpload(api.files.generateUploadUrl),
useConvexFileUpload(api.files.generateUploadUrl),
useConvexFileUpload(api.files.generateUploadUrl),
]
const items = ref<
Array<{
file: File
status: 'queued' | 'uploading' | 'success' | 'error'
storageId?: Id<'_storage'>
error?: unknown
}>
>([])
async function uploadFiles(files: File[]) {
items.value = files.map((file) => ({ file, status: 'queued' }))
let next = 0
await Promise.all(
workers.map(async (worker) => {
while (next < items.value.length) {
const item = items.value[next++]!
item.status = 'uploading'
try {
item.storageId = (await worker.upload(item.file)).storageId
item.status = 'success'
} catch (error) {
item.error = error
item.status = 'error'
}
}
}),
)
}Choose the worker count from product and network requirements. Add retries, cancellation mapping, resumability, or server-side job records only when the workflow needs them.