3 min read
On this page

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-Type manually for FormData. The browser must set the Content-Type header 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 call URL.revokeObjectURL. Always revoke in onUnmounted.
  • Using array index as :key in 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 accept attribute is a hint, not a security measure. Users can still select disallowed files. Validate file.type and file.size in 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 change event, not v-model. Access File objects from event.target.files.
  • FormData packages files and fields for multipart upload. Never set the Content-Type header manually.
  • URL.createObjectURL creates instant image previews. Always revoke URLs to prevent memory leaks.
  • XMLHttpRequest provides upload progress events that fetch does not support natively.
  • Multi-step forms store all data in a single reactive object at the parent level. Steps are rendered with v-if and 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.