File Uploads & Complex Forms
Beyond simple text inputs, real applications need file uploads, multi-step wizards, and dynamic form fields that users can add or remove. These patterns appear in nearly every production app: profile photo uploads, onboarding flows, invoice line items, survey builders. Each introduces complexity that basic v-model usage does not cover.
Handling File Inputs
HTML file inputs do not work with v-model. Instead, listen for the change event and access the File objects from the event target:
<script setup lang="ts">
const selectedFile = ref<File | null>(null)
function onFileChange(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0] ?? null
selectedFile.value = file
}
</script>
<template>
<div>
<label>
Upload Document
<input type="file" accept=".pdf,.doc,.docx" @change="onFileChange" />
</label>
<p v-if="selectedFile">
Selected: {{ selectedFile.name }} ({{ (selectedFile.size / 1024).toFixed(1) }} KB)
</p>
</div>
</template>
Multiple Files
<script setup lang="ts">
const files = ref<File[]>([])
function onFilesChange(event: Event) {
const target = event.target as HTMLInputElement
files.value = Array.from(target.files ?? [])
}
function removeFile(index: number) {
files.value.splice(index, 1)
}
</script>
<template>
<div>
<input type="file" multiple accept="image/*" @change="onFilesChange" />
<ul>
<li v-for="(file, index) in files" :key="file.name">
{{ file.name }} - {{ (file.size / 1024).toFixed(1) }} KB
<button @click="removeFile(index)">Remove</button>
</li>
</ul>
</div>
</template>
Uploading with FormData
The FormData API is required for multipart file uploads. It packages files and other fields into the format servers expect:
<script setup lang="ts">
const form = reactive({
title: '',
description: ''
})
const file = ref<File | null>(null)
const isUploading = ref(false)
function onFileChange(event: Event) {
const target = event.target as HTMLInputElement
file.value = target.files?.[0] ?? null
}
async function handleSubmit() {
if (!file.value) return
const formData = new FormData()
formData.append('title', form.title)
formData.append('description', form.description)
formData.append('file', file.value)
isUploading.value = true
try {
await $fetch('/api/documents', {
method: 'POST',
body: formData
// Do NOT set Content-Type header -- the browser sets it with the boundary
})
alert('Upload complete')
} catch (err) {
alert('Upload failed')
} finally {
isUploading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.title" type="text" placeholder="Document title" required />
<textarea v-model="form.description" placeholder="Description"></textarea>
<input type="file" accept=".pdf,.doc,.docx" @change="onFileChange" required />
<button type="submit" :disabled="isUploading">
{{ isUploading ? 'Uploading...' : 'Upload' }}
</button>
</form>
</template>
The server handler reads the file with Nitro utilities:
// server/api/documents.post.ts
export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event)
if (!form) throw createError({ statusCode: 400, message: 'No form data' })
const fileField = form.find((f) => f.name === 'file')
const titleField = form.find((f) => f.name === 'title')
if (!fileField || !fileField.data) {
throw createError({ statusCode: 400, message: 'File is required' })
}
// fileField.data is a Buffer, fileField.filename has the original name
await saveFile(fileField.filename!, fileField.data)
return { success: true, title: titleField?.data.toString() }
})
Preview Before Upload
For images, create a local preview using URL.createObjectURL:
<script setup lang="ts">
const previewUrl = ref<string | null>(null)
const selectedFile = ref<File | null>(null)
function onImageChange(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
// Revoke previous URL to prevent memory leaks
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
selectedFile.value = file
previewUrl.value = URL.createObjectURL(file)
}
}
onUnmounted(() => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
})
</script>
<template>
<div>
<label>
Profile Photo
<input type="file" accept="image/png,image/jpeg" @change="onImageChange" />
</label>
<div v-if="previewUrl" class="preview">
<img :src="previewUrl" alt="Preview" width="200" />
<p>{{ selectedFile?.name }}</p>
</div>
</div>
</template>
Upload Progress Tracking
XMLHttpRequest provides progress events that fetch does not. For uploads where progress feedback matters:
<script setup lang="ts">
const progress = ref(0)
const isUploading = ref(false)
function uploadWithProgress(file: File): Promise<void> {
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) {
progress.value = Math.round((event.loaded / event.total) * 100)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed: ${xhr.status}`))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.open('POST', '/api/upload')
xhr.send(formData)
})
}
async function handleUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
isUploading.value = true
progress.value = 0
try {
await uploadWithProgress(file)
alert('Upload complete')
} catch (err) {
alert('Upload failed')
} finally {
isUploading.value = false
}
}
</script>
<template>
<div>
<input type="file" @change="handleUpload" :disabled="isUploading" />
<div v-if="isUploading" class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
<span>{{ progress }}%</span>
</div>
</div>
</template>
<style scoped>
.progress-bar {
width: 100%;
height: 24px;
background: #e5e7eb;
border-radius: 4px;
position: relative;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
background: #10b981;
border-radius: 4px;
transition: width 0.2s;
}
.progress-bar span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.75rem;
}
</style>
Multi-Step Forms (Wizards)
Multi-step forms split a long form into manageable sections. State must persist across steps, and users should be able to navigate back without losing data.
<script setup lang="ts">
const currentStep = ref(1)
const totalSteps = 3
const form = reactive({
// Step 1: Personal
firstName: '',
lastName: '',
email: '',
// Step 2: Company
company: '',
role: '',
teamSize: '',
// Step 3: Preferences
plan: 'free',
newsletter: true
})
function nextStep() {
if (currentStep.value < totalSteps) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 1) {
currentStep.value--
}
}
async function handleSubmit() {
await $fetch('/api/onboarding', {
method: 'POST',
body: { ...form }
})
}
</script>
<template>
<div>
<div class="step-indicator">
Step {{ currentStep }} of {{ totalSteps }}
</div>
<form @submit.prevent="handleSubmit">
<!-- Step 1: Personal Info -->
<fieldset v-if="currentStep === 1">
<legend>Personal Information</legend>
<input v-model="form.firstName" type="text" placeholder="First name" required />
<input v-model="form.lastName" type="text" placeholder="Last name" required />
<input v-model="form.email" type="email" placeholder="Email" required />
</fieldset>
<!-- Step 2: Company -->
<fieldset v-if="currentStep === 2">
<legend>Company Details</legend>
<input v-model="form.company" type="text" placeholder="Company name" />
<input v-model="form.role" type="text" placeholder="Your role" />
<select v-model="form.teamSize">
<option value="">Team size</option>
<option value="1-5">1-5</option>
<option value="6-20">6-20</option>
<option value="21-100">21-100</option>
<option value="100+">100+</option>
</select>
</fieldset>
<!-- Step 3: Preferences -->
<fieldset v-if="currentStep === 3">
<legend>Preferences</legend>
<label>
<input v-model="form.plan" type="radio" value="free" /> Free
</label>
<label>
<input v-model="form.plan" type="radio" value="pro" /> Pro
</label>
<label>
<input v-model="form.newsletter" type="checkbox" /> Subscribe to newsletter
</label>
</fieldset>
<div class="wizard-nav">
<button type="button" @click="prevStep" :disabled="currentStep === 1">
Back
</button>
<button v-if="currentStep < totalSteps" type="button" @click="nextStep">
Next
</button>
<button v-else type="submit">
Complete
</button>
</div>
</form>
</div>
</template>
The single reactive object holds all data across steps. Because v-if unmounts step components, the data must live outside the steps in the parent scope.
Dynamic Form Fields
Some forms let users add or remove rows -- invoice line items, addresses, team members. Use an array inside a reactive object:
<script setup lang="ts">
interface LineItem {
id: number
description: string
quantity: number
unitPrice: number
}
let nextId = 1
const items = ref<LineItem[]>([
{ id: nextId++, description: '', quantity: 1, unitPrice: 0 }
])
function addItem() {
items.value.push({
id: nextId++,
description: '',
quantity: 1,
unitPrice: 0
})
}
function removeItem(index: number) {
if (items.value.length > 1) {
items.value.splice(index, 1)
}
}
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
)
async function handleSubmit() {
await $fetch('/api/invoices', {
method: 'POST',
body: { items: items.value, total: total.value }
})
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<table>
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="item.id">
<td>
<input v-model="item.description" type="text" placeholder="Item description" />
</td>
<td>
<input v-model.number="item.quantity" type="number" min="1" />
</td>
<td>
<input v-model.number="item.unitPrice" type="number" min="0" step="0.01" />
</td>
<td>
{{ (item.quantity * item.unitPrice).toFixed(2) }}
</td>
<td>
<button type="button" @click="removeItem(index)" :disabled="items.length === 1">
Remove
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td>{{ total.toFixed(2) }}</td>
<td></td>
</tr>
</tfoot>
</table>
<button type="button" @click="addItem">Add Line Item</button>
<button type="submit">Create Invoice</button>
</form>
</template>
Key to Dynamic Fields: Stable Keys
Always use a stable unique id for :key, not the array index. When items are removed from the middle of the array, index-based keys cause Vue to re-render the wrong elements, losing input focus and potentially swapping values between rows.
Drag-and-Drop File Upload
A more polished file upload experience uses drag-and-drop:
<script setup lang="ts">
const isDragging = ref(false)
const droppedFiles = ref<File[]>([])
function onDrop(event: DragEvent) {
isDragging.value = false
const files = event.dataTransfer?.files
if (files) {
droppedFiles.value = Array.from(files)
}
}
function onDragOver(event: DragEvent) {
event.preventDefault()
isDragging.value = true
}
function onDragLeave() {
isDragging.value = false
}
</script>
<template>
<div
class="drop-zone"
:class="{ dragging: isDragging }"
@drop.prevent="onDrop"
@dragover="onDragOver"
@dragleave="onDragLeave"
>
<p v-if="droppedFiles.length === 0">Drag files here or click to browse</p>
<ul v-else>
<li v-for="file in droppedFiles" :key="file.name">
{{ file.name }} ({{ (file.size / 1024).toFixed(1) }} KB)
</li>
</ul>
<input type="file" multiple @change="onFilesChange" class="hidden-input" />
</div>
</template>
<style scoped>
.drop-zone {
border: 2px dashed #d1d5db;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
}
.drop-zone.dragging {
border-color: #10b981;
background: #f0fdf4;
}
.hidden-input {
display: none;
}
</style>
Common Pitfalls
- Setting
Content-Typemanually for FormData. The browser must set theContent-Typeheader with the multipart boundary. Setting it manually breaks the upload. - Memory leaks from
URL.createObjectURL. Every call creates a blob URL that persists until the page unloads or you callURL.revokeObjectURL. Always revoke inonUnmounted. - Using array index as
:keyin dynamic fields. Causes rendering bugs when items are added or removed from the middle. Use a stable unique ID. - Losing wizard state on component unmount. If each step is a separate component and the state lives inside it, navigating between steps destroys the data. Keep state in the parent or a composable.
- Not validating file types and sizes on the client. The
acceptattribute is a hint, not a security measure. Users can still select disallowed files. Validatefile.typeandfile.sizein your change handler. - Submitting multi-step forms without validating all steps. The user might skip a required field in step 1 and only submit in step 3. Validate all steps before final submission.
Key Takeaways
- File inputs use the
changeevent, notv-model. AccessFileobjects fromevent.target.files. FormDatapackages files and fields for multipart upload. Never set theContent-Typeheader manually.URL.createObjectURLcreates instant image previews. Always revoke URLs to prevent memory leaks.XMLHttpRequestprovides upload progress events thatfetchdoes not support natively.- Multi-step forms store all data in a single reactive object at the parent level. Steps are rendered with
v-ifand navigation is controlled by a step counter. - Dynamic form fields use arrays with stable unique IDs for
:key. Users can add and remove rows freely.