Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 068 / 100

File uploads in Vue: Vue & Nuxt best practices explained

Upload with progress.

Expona Admin8 min read

File uploads in Vue: Vue & Nuxt best practices explained

File uploads are deceptively complex. You need to handle multipart form data, show real-time progress to users, validate before sending, and consider where files actually land. We'll walk through the patterns that have proven reliable across production e-commerce, SaaS, and travel platforms.

Understanding FormData with fetch and axios

Whether you're using the modern fetch API or axios, the foundation is always FormData. This API handles multipart/form-data encoding automatically, which is what servers expect for file uploads.

With fetch, the setup is straightforward:

typescript
async function uploadFile(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  formData.append('name', file.name)
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  
  return response.json()
}

Axios handles FormData equally well, and many teams prefer it for consistent interceptor patterns:

typescript
async function uploadFileAxios(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  
  const response = await axios.post('/api/upload', formData, {
    headers: {
      'Content-Type': 'multipart/form-data'
    }
  })
  
  return response.data
}

One critical detail: don't set the Content-Type header manually when using FormData. Both fetch and axios will set it with the correct boundary automatically. Setting it yourself breaks the request.

For multiple files, iterate and append each:

typescript
function uploadMultiple(files: FileList) {
  const formData = new FormData()
  
  Array.from(files).forEach((file) => {
    formData.append('files', file)
  })
  
  return axios.post('/api/upload-batch', formData)
}

Showing upload progress with XHR and upload events

Displaying progress transforms the user experience from "is this hanging?" to "23% complete." Native fetch doesn't expose upload progress, so for real-time feedback you need either XMLHttpRequest's upload event or axios request interceptors combined with a custom implementation.

Here's the XHR approach, which gives you the most control:

typescript
function uploadWithProgress(
  file: File,
  onProgress: (percent: number) => void
): Promise {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    const formData = new FormData()
    formData.append('file', file)
    
    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        const percent = Math.round(
          (event.loaded / event.total) * 100
        )
        onProgress(percent)
      }
    })
    
    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText))
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`))
      }
    })
    
    xhr.addEventListener('error', () => {
      reject(new Error('Network error during upload'))
    })
    
    xhr.open('POST', '/api/upload')
    xhr.send(formData)
  })
}

In a Vue component, you'd bind this to reactive state:

typescript
export default {
  data() {
    return {
      uploadProgress: 0,
      isUploading: false
    }
  },
  methods: {
    async handleFileSelect(event: Event) {
      const input = event.target as HTMLInputElement
      const file = input.files?.[0]
      if (!file) return
      
      this.isUploading = true
      this.uploadProgress = 0
      
      try {
        await uploadWithProgress(file, (percent) => {
          this.uploadProgress = percent
        })
      } finally {
        this.isUploading = false
      }
    }
  }
}

For Nuxt with the Composition API, extract this into a composable:

typescript
export const useFileUpload = () => {
  const progress = ref(0)
  const isLoading = ref(false)
  const error = ref(null)
  
  const upload = async (file: File) => {
    isLoading.value = true
    error.value = null
    
    try {
      return await uploadWithProgress(file, (p) => {
        progress.value = p
      })
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Upload failed'
      throw err
    } finally {
      isLoading.value = false
    }
  }
  
  return { upload, progress, isLoading, error }
}

Validating file size and type before upload

Never trust the client alone, but validate early to avoid wasting bandwidth. Size and type checks happen instantly on the client before the request even starts.

Create a validation utility:

typescript
interface ValidationRule {
  maxSize?: number // bytes
  allowedTypes?: string[]
}

function validateFile(
  file: File,
  rules: ValidationRule
): { valid: boolean; error?: string } {
  if (rules.maxSize && file.size > rules.maxSize) {
    const mb = (rules.maxSize / 1024 / 1024).toFixed(2)
    return {
      valid: false,
      error: `File exceeds ${mb}MB limit`
    }
  }
  
  if (rules.allowedTypes && 
      !rules.allowedTypes.includes(file.type)) {
    return {
      valid: false,
      error: `File type ${file.type} not allowed`
    }
  }
  
  return { valid: true }
}

// Usage
const result = validateFile(file, {
  maxSize: 5 * 1024 * 1024, // 5MB
  allowedTypes: ['image/jpeg', 'image/png', 'application/pdf']
})

if (!result.valid) {
  console.error(result.error)
  return
}

In a Vue component, integrate validation into your file input handler:

typescript
async handleFileSelect(event: Event) {
  const input = event.target as HTMLInputElement
  const file = input.files?.[0]
  if (!file) return
  
  const validation = validateFile(file, {
    maxSize: 10 * 1024 * 1024,
    allowedTypes: ['image/jpeg', 'image/png']
  })
  
  if (!validation.valid) {
    this.errorMessage = validation.error
    return
  }
  
  await this.performUpload(file)
}

For advanced use cases like image dimensions, you may need to read the file before uploading, but keep this lightweight to avoid delays.

Direct-to-S3 uploads with pre-signed URLs

For high-volume uploads or large files, sending through your backend is inefficient. Pre-signed URLs let clients upload directly to S3 while keeping your backend in the security loop.

The flow: client requests a pre-signed URL from your backend, backend generates one via AWS SDK, client uses it to PUT the file to S3.

Backend endpoint (Node.js example):

typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const s3 = new S3Client({ region: 'us-east-1' })

export async function getPresignedUrl(
  fileName: string,
  contentType: string
): Promise {
  const key = `uploads/${Date.now()}-${fileName}`
  
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: contentType
  })
  
  const url = await getSignedUrl(s3, command, { expiresIn: 3600 })
  return url
}

On the Vue client side, request the URL then upload directly:

typescript
async uploadToS3(file: File) {
  try {
    // Step 1: Get pre-signed URL from your backend
    const { url } = await axios.post('/api/get-upload-url', {
      fileName: file.name,
      contentType: file.type
    })
    
    // Step 2: Upload directly to S3
    await axios.put(url, file, {
      headers: {
        'Content-Type': file.type
      },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.total) {
          const percent = Math.round(
            (progressEvent.loaded / progressEvent.total) * 100
          )
          this.uploadProgress = percent
        }
      }
    })
    
    console.log('Upload successful')
  } catch (error) {
    console.error('Upload failed:', error)
  }
}

Pre-signed URLs expire, so generate them fresh for each upload session. In production, validate that the file type matches what was requested—S3 won't, so enforce this server-side.

Common mistakes

  • Setting Content-Type header with FormData: Let the browser set it. Manual headers break multipart encoding.
  • Ignoring CORS on direct S3 uploads: Configure your S3 bucket's CORS policy to allow your domain. Without this, PUT requests fail silently in the browser.
  • No server-side validation: Client-side checks are UX, not security. Always re-validate file type, size, and content on the backend.
  • Forgetting to validate pre-signed URL metadata: If using pre-signed URLs, ensure the server verifies that the uploaded file matches the intended type before accepting it into your application.
  • Not handling upload cancellation: XHR requests can be aborted, but you need to track the abort controller and wire it to a cancel button for good UX on slow networks.

Key takeaways

  • FormData handles multipart encoding; use it with fetch or axios without manually setting Content-Type.
  • Progress tracking requires XHR or axios interceptors—native fetch alone won't give you upload events.
  • Validate file size and type on the client for instant feedback, but always re-validate server-side.
  • Pre-signed URLs scale direct-to-S3 uploads while keeping your backend secure; generate them on request with short expiry times.
  • Test uploads over slow networks and with large files to catch edge cases in progress reporting and cancellation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles