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:
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:
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:
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:
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:
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:
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:
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:
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):
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:
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.






Comments
Loading comments…