File uploads
Pick files without v-model, check their type and size, show previews with object URLs and release them, then send them as multipart FormData to a Nuxt route that checks what it really received.
What you'll learn
When you finish, you can do these.- 1Read picked and dropped files without v-model
- 2Check each file's type and size before uploading
- 3Show image previews, then free the memory they hold
- 4Send files in a FormData and check them on the server
Picture a page where someone renting out a room adds photos of it, with a caption. The photos come in through a file input, <input type="file">, which does not work like a text field. v-model, Vue's two-way link between a field and a value, refuses to compile on it, and its File objects cannot be sent as JSON, the text format a form's values usually go in. A phone photo can weigh 6 MB, so a wrong file is best caught before the upload.
The Code tab holds that photo form: App.vue reads the photos, files.ts checks them, PreviewList.vue previews them, and the Build the request button prints what would be sent. upload.ts sends the photos and server/api/upload.post.ts checks them on the server, in a Nuxt app, a Vue app with its own server; the Code tab's playground does not run them. You need one idea from the Form libraries lesson: $fetch, Nuxt's request helper, sends a form's values to the server as JSON, and the server checks them again.
Pick files without v-model
A file input hands its files to your code through an event, not through v-model. Put v-model on one and the compiler stops with an error: a page may read which files were chosen but never set them, and v-model needs to do both.
Listen for change instead, with @change, short for v-on:change. In App.vue the listener is onPick(): it reads files from event.target, the input, and hands them to take(), which checks and keeps them.
function onPick(event: Event) {
take((event.target as HTMLInputElement).files)
}
files is a FileList, a list you can loop over. Each File in it has a name, a size in bytes and a type such as image/jpeg, which the operating system guesses from the extension, so a renamed file lies without knowing it.
Reading the files is the first step on their way to the server:
- input type=file
- FormData
- fetch or $fetchmultipart body
- ServerreadMultipartFormData
The <label> around the input is also a drop zone, an area you can drag files onto. On drop, onDrop() hands the dropped files, event.dataTransfer.files, to the same take().
<label class="drop" @dragover.prevent @drop.prevent="onDrop">
Drop photos here, or choose files
<input type="file" accept="image/*" multiple @change="onPick" />
</label>
The label must cancel dragover, the event that fires while a file is held over it, or the browser opens the dropped file instead; @dragover.prevent does that.
accept="image/*" filters the picker dialog and multiple allows several files. Neither checks anything, and dropped files skip accept, so every file is checked in code.
Check, preview, and let go
Every file goes through take(), which checks it and keeps the good ones with a preview.
The check is problem() in files.ts. It returns a reason when a file's type is not an image or its size is over MAX_BYTES, 5 MB, and an empty string otherwise.
export function problem(file: File) {
if (!file.type.startsWith('image/')) return `${file.name} is not an image`
if (file.size > MAX_BYTES) return `${file.name} is too big (${formatSize(file.size)}, max ${formatSize(MAX_BYTES)})`
return ''
}
These checks give the person quick feedback, with the real size in the message; for safety, the server repeats them.
take() in App.vue puts each reason in problems, the red list on the form, and each good file in picked, with a preview address. Its first lines clear what the previous pick left.
function take(list: FileList | null) {
clear()
problems.value = []
request.value = []
for (const file of list ?? []) {
const why = problem(file)
if (why) problems.value.push(why)
else picked.value.push({ file, url: URL.createObjectURL(file) })
}
}
Drop a photo and a text file such as notes.txt together: the photo is kept, and the red list says notes.txt is not an image.
The preview address comes from URL.createObjectURL(file): a blob: address an <img> can load straight from memory. A second component, PreviewList.vue, gets picked as items and shows each file.
<li v-for="({ file, url }, index) in items" :key="url">
<img :src="url" :alt="file.name" />
<span>{{ file.name }}</span>
<small>{{ formatSize(file.size) }}, {{ file.type }}</small>
</li>
Each card shows the picture, its name and a line such as 1.8 MB, image/jpeg, before anything is uploaded.
Every address keeps its file in memory until you call URL.revokeObjectURL(url) or the page unloads. App.vue revokes them in clear(). take() runs it before making new previews, and onBeforeUnmount, a hook Vue calls right before the component leaves the page, runs it too.
function clear() {
for (const { url } of picked.value) URL.revokeObjectURL(url)
picked.value = []
}
onBeforeUnmount(clear)
The page looks the same; the browser lets go of the old photos.
Send it: FormData, multipart and the server
JSON has no place for bytes, so files travel in a FormData, a form's fields and files in one object. You append() each File under a name, the same name for every file, and each text field under its own. The Build the request button builds one and prints its entries: what the server will see.
const body = new FormData()
for (const { file } of picked.value) body.append('photos', file)
body.append('caption', caption.value)
With one photo and no caption, it prints photos: photo.jpg (1.8 MB) and caption: "".
In a real app, upload.ts sends the FormData with fetch('/api/upload', { method: 'POST', body }), and $fetch takes the same body. Do not set Content-Type, the header that names the body's format: the browser writes multipart/form-data; boundary=----... itself, and the boundary it generated is how the server splits the parts.
fetch reports no upload progress. For a progress bar, uploadWithProgress() in the same file uses XMLHttpRequest, whose upload object fires progress events in every browser.
The request arrives at server/api/upload.post.ts, a Nuxt server route: the code that answers POST requests to /api/upload. readMultipartFormData(event) returns the parts, each with the name it was appended under, its filename, type and data, the bytes. For a body that is not multipart, it returns undefined.
Then the route checks each photo's size and real type and stores it under a name it chose.
const parts = await readMultipartFormData(event)
const photos = parts?.filter(part => part.name === 'photos' && part.filename) ?? []
for (const photo of photos) {
if (photo.data.length > MAX_BYTES) throw createError({ status: 413, statusText: `${photo.filename} is too large` })
const kind = kindOf(photo.data)
if (!kind) throw createError({ status: 415, statusText: `${photo.filename} is not a JPEG or PNG` })
await writeFile(`./uploads/${randomUUID()}.${kind}`, photo.data)
}
kindOf() reads the first bytes, the signature a real JPEG or PNG starts with, because the extension, the filename and the type are whatever the client, the sending side, claims. A failed check stops the upload with status 413, too large, or 415, not a JPEG or PNG.
What goes wrong
The first mistake treats the file input like a text input.
<input v-model="picked" type="file" accept="image/*" multiple />
The playground's status bar shows v-model cannot be used on file inputs since they are read-only. Use a v-on:change listener instead. and the preview shows nothing. Read the files on change.
<input type="file" accept="image/*" multiple @change="onPick" />
The second makes previews and never lets go. This take() replaces the array and forgets the old addresses.
function take(list: FileList | null) {
picked.value = [...list ?? []].map(file => ({ file, url: URL.createObjectURL(file) }))
}
Nothing looks wrong on the page, but each pick adds addresses that keep their files in memory until the tab closes: twenty picks of a 4 MB photo hold 80 MB nobody can see. Revoke before you replace, and when the component unmounts.
function take(list: FileList | null) {
clear()
for (const file of list ?? []) picked.value.push({ file, url: URL.createObjectURL(file) })
}
What to remember
- A file input has no
v-model. Readevent.target.filesonchange, andevent.dataTransfer.filesondropafter cancellingdragover. acceptfilters the picker only: check each file's type and size in code before uploading, and again on the server.URL.createObjectURL(file)gives an address an<img>can show; every one holds memory untilURL.revokeObjectURL(), so revoke before replacing and on unmount.- Send files in a
FormDatabody and never setContent-Typeyourself; the browser adds the boundary. OnlyXMLHttpRequestreports upload progress. - On the server,
readMultipartFormData()gives each part's bytes; check the size, read the signature bytes for the real type, and store under a name you chose.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
files.ts, setMAX_BYTESto200 * 1024and pick a photo: almost any photo is bigger, so the list says something like photo.jpg is too big (1.8 MB, max 200 KB) and no preview appears. Put5 * 1024 * 1024back. - In
PreviewList.vue, addconst emit = defineEmits<{ remove: [index: number] }>()and a<button type="button" @click="emit('remove', index)">Remove</button>inside the<li>. InApp.vue, writefunction remove(index: number) { URL.revokeObjectURL(picked.value[index].url); picked.value.splice(index, 1) }and listen with<PreviewList :items="picked" @remove="remove" />. Pick two photos and remove the first: its preview disappears, and Build the request lists one file. - Add
v-model="picked"to the file input. The status bar shows v-model cannot be used on file inputs since they are read-only, and the preview is blank. Remove it again: the form is back.