Skip to main content

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:

  1. Prepare: a mutation returns an upload URL.
  2. Upload: the browser sends the bytes to Convex storage and receives a storage ID.
  3. Complete (optional): your mutation or action records the file, for example on a document.

Backend

convex/files.ts
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

vue
<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:

  • storageId is the branded Convex storage ID (Id<'_storage'>).
  • prepared is the result of the prepare mutation.
  • completed is the result of complete, or undefined without 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 to idle. A running storage upload is aborted, and a step that was not sent is never sent. upload() then rejects with code CANCELLED. A Convex call that was already sent still finishes. If that call was the last call of complete, upload() resolves with the result, but data stays 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 clears data, error, and progress.
  • 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.phase is the step that failed: prepare, upload, or complete. Every earlier step succeeded. Checks that run before any request, such as FILE_TOO_LARGE, have no phase.
  • error.outcome tells whether the failed phase sent a request: not-sent or unknown. It is undefined when 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.

CodeWhen
FILE_TOO_LARGEThe file is larger than maxSize; no request is sent
FILE_TYPE_NOT_ALLOWEDThe MIME type does not match allowedTypes; no request is sent
UPLOAD_IN_PROGRESSAnother upload of the same composable is pending
CANCELLEDcancel(), reset(), or unmounting stopped the upload
IDENTITY_CHANGEDThe signed-in user changed during the upload
CLIENT_UNAVAILABLENo browser Convex client exists, for example during server rendering
INVALID_UPLOAD_URLThe 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:

ts
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:

ts
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:

ts
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:

ts
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.