Advanced

File Storage

Upload and manage files with Convex storage and Nuxt.
Convex provides built-in file storage. See the Convex File Storage docs for backend details.

Composables

This module provides two composables for file storage:

ComposableDescription
useConvexFileUploadUpload files with progress tracking and cancel support
useConvexStorageUrlGet reactive URLs for stored files

Backend Setup

Create the required Convex functions:

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

useConvexFileUpload

Upload files with automatic progress tracking and cancel support.

Return Value

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)

Options

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
})

Basic Usage

components/FileUpload.vue
<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>

With Cancel Support

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

With Callbacks

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

With Mutation Args

Pass arguments to the generateUploadUrl mutation (for auth, metadata, etc.):

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

Saving to a Document

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>

useConvexStorageUrl

Get a reactive URL for a file in storage. Automatically skips the query when storageId is null/undefined.

Basic Usage

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

With useConvexFileUpload

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

From Document Data

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

Complete Example

A full file upload component with progress, cancel, validation, and display:

components/ImageUploader.vue
<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>

File Validation

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 video
  • audio/* - Any audio
  • application/* - Any application type

You can mix wildcards with exact types: ['image/*', 'application/pdf']

Validation errors are tracked in the error ref just like upload errors.


Delete Files

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