Quasar file upload comes in two shapes: q-file, a form field that only picks files, and q-uploader, a self-contained widget that picks, queues and sends them. They look similar in the docs and solve different problems. This post builds one Nuxt server route that accepts files, then sends files to it both ways: QFile inside a form with a real upload progress bar, and QUploader with its own queue, progress and retries. Everything is checked against Quasar 2.35.2 and Nuxt 4.
q-file | q-uploader | |
|---|---|---|
| What it is | A field, like q-input, whose model is a File or an array of files | A card with a header, a file list and upload buttons |
| Sends the files | No. You write the request | Yes, with XMLHttpRequest, to url |
| Upload progress | Yours to build | Per file and in total, in the header |
| Validation | rules, and it takes part in q-form | accept, max-file-size, max-files, filter |
| Drag and drop | Yes | Yes |
| Best for | A file that belongs to a form: an avatar, an attachment on a ticket | A standalone upload area: a media library, bulk imports |
Both share the same picking restrictions (accept, max-file-size, max-total-size, max-files, filter) and emit @rejected with { failedPropValidation, file } entries for files that fail them. These checks run in the browser, for the user's convenience. The server has to check everything again.
Both clients post multipart/form-data to /api/uploads, with each file in a field named file. Nuxt server routes run on h3, which reads multipart bodies with readMultipartFormData(). Each part comes back as { name, filename, type, data }, where data is a Buffer. If you haven't built a server route before, the Nuxt server routes guide covers the file naming, validation and status codes this handler relies on.
// server/api/uploads.post.ts
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const MAX_BYTES = 5 * 1024 * 1024
const EXTENSIONS: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'application/pdf': '.pdf',
}
const UPLOAD_DIR = join(process.cwd(), '.data', 'uploads')
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event)
const files = (parts ?? []).filter(part => part.name === 'file' && part.filename)
if (files.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'Send at least one file in the "file" field' })
}
for (const file of files) {
if (!file.type || !(file.type in EXTENSIONS)) {
throw createError({ statusCode: 415, statusMessage: 'Only PNG, JPEG, WebP and PDF files' })
}
if (file.data.length > MAX_BYTES) {
throw createError({ statusCode: 413, statusMessage: 'Files must be 5 MB or smaller' })
}
}
await mkdir(UPLOAD_DIR, { recursive: true })
const saved = []
for (const file of files) {
const id = randomUUID() + EXTENSIONS[file.type!]
await writeFile(join(UPLOAD_DIR, id), file.data)
saved.push({ id, name: file.filename, size: file.data.length })
}
setResponseStatus(event, 201)
return { files: saved }
})A few decisions worth copying:
file.filename. The original name is returned as data only.type is what the browser claims. If it matters, check the file's first bytes too.readMultipartFormData() buffers the whole body in memory (in h3 1.15 it reads the raw body, then parses it). Fine for a few megabytes; for large files, upload straight to object storage with a signed URL.In a real app, put a session check at the top of the handler too. A route under server/api/ is public until you make it otherwise.
QFile only picks files. To send them with a progress bar you need XMLHttpRequest, because fetch has no upload progress events. Here's a small helper that wraps it in a promise and supports cancelling:
// app/utils/upload-with-progress.ts
export function uploadWithProgress(
url: string,
files: File[],
onProgress: (fraction: number) => void,
signal?: AbortSignal,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const form = new FormData()
for (const file of files) form.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText))
else reject(new Error(xhr.statusText || `Upload failed (${xhr.status})`))
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.addEventListener('abort', () => reject(new DOMException('Upload cancelled', 'AbortError')))
signal?.addEventListener('abort', () => xhr.abort())
xhr.open('POST', url)
xhr.send(form)
})
}Over HTTP/2 the status text is empty, hence the fallback to the code. And don't set Content-Type by hand: the browser adds the multipart boundary itself for a FormData.
Now the form. QFile sits in a q-form like any other field, with a rule, and the progress bar shows while the request runs:
<script setup lang="ts">
import { ref } from 'vue'
import { useQuasar, type QRejectedEntry } from 'quasar'
const MAX_BYTES = 5 * 1024 * 1024
const $q = useQuasar()
const files = ref<File[] | null>(null)
const progress = ref(0)
const uploading = ref(false)
const required = (value: File[] | null) => (value?.length ?? 0) > 0 || 'Pick at least one file'
function onRejected(entries: QRejectedEntry[]) {
$q.notify({ type: 'negative', message: `${entries.length} file(s) skipped: wrong type or over 5 MB` })
}
async function submit() {
if (!files.value?.length) return
uploading.value = true
progress.value = 0
try {
await uploadWithProgress('/api/uploads', files.value, (fraction) => {
progress.value = fraction
})
$q.notify({ type: 'positive', message: 'Uploaded' })
files.value = null
}
catch (error) {
$q.notify({ type: 'negative', message: (error as Error).message })
}
finally {
uploading.value = false
}
}
</script>
<template>
<q-form class="q-gutter-md" @submit="submit">
<q-file
v-model="files"
label="Attachments"
outlined
multiple
use-chips
counter
accept="image/png, image/jpeg, image/webp, application/pdf"
:max-file-size="MAX_BYTES"
:rules="[required]"
:disable="uploading"
@rejected="onRejected"
>
<template #prepend>
<q-icon name="attach_file" />
</template>
</q-file>
<q-linear-progress v-if="uploading" :value="progress" rounded size="8px" />
<q-btn type="submit" color="primary" label="Upload" :loading="uploading" />
</q-form>
</template>Notes on the QFile side:
multiple, the model is an array. Without it, the model is a single File. Start from null either way: the Quasar docs point out that a native file input can't be filled by code, so a preset model shows in the field but isn't in the input.append keeps earlier picks when the user opens the picker again. Without it, each pick replaces the model.#file slot renders each file yourself, for example a chip with its own progress bar.$q.notify needs the Notify plugin enabled in your Quasar config.If you don't need progress, skip the helper: build a FormData and send it with $fetch('/api/uploads', { method: 'POST', body: form }).
QUploader does the request for you. Point it at the same route:
<script setup lang="ts">
import { ref } from 'vue'
import { useQuasar, type QRejectedEntry } from 'quasar'
const $q = useQuasar()
const uploadedIds = ref<string[]>([])
function onRejected(entries: QRejectedEntry[]) {
$q.notify({ type: 'negative', message: `${entries.length} file(s) skipped: wrong type or over 5 MB` })
}
function onUploaded({ xhr }: { xhr: XMLHttpRequest }) {
const { files } = JSON.parse(xhr.responseText) as { files: { id: string, name: string }[] }
uploadedIds.value.push(...files.map(file => file.id))
}
function onFailed({ xhr }: { xhr: XMLHttpRequest }) {
$q.notify({ type: 'negative', message: xhr.statusText || `Upload failed (${xhr.status})` })
}
</script>
<template>
<q-uploader
url="/api/uploads"
field-name="file"
label="PNG, JPEG, WebP or PDF, up to 5 MB"
multiple
accept="image/png, image/jpeg, image/webp, application/pdf"
:max-file-size="5 * 1024 * 1024"
flat
bordered
style="max-width: 360px"
@rejected="onRejected"
@uploaded="onUploaded"
@failed="onFailed"
/>
</template>What it does, read from the 2.35.2 source:
field-name is not optional in practice. It defaults to the file's own name, so a file called cat.png arrives in a form field named cat.png. Set field-name="file" and the server reads one predictable field.batch to send them all in one request, which the server above also accepts.@failed and puts the file back in the queue, so the upload button retries it.xhr.upload events, per file and in the header. auto-upload starts as soon as files are added.Same-origin requests send cookies by themselves. For a token or extra data, use headers and form-fields, or the factory prop, which can return a promise, for example after asking your API for an upload URL:
async function factory(files?: readonly File[]) {
const { url, token } = await $fetch<{ url: string, token: string }>('/api/upload-ticket', {
method: 'POST',
body: { count: files?.length ?? 0 },
})
return {
url,
fieldName: 'file',
headers: [{ name: 'Authorization', value: `Bearer ${token}` }],
}
}Bind it with :factory="factory" and drop the url prop. If the function throws or its promise rejects, QUploader emits @factory-failed.
Use QFile when the file is one field of a larger form. It validates with the rest of the form, the user submits once, and the upload can be part of the same request as the other fields. You own the request, so you also own progress and errors.
Use QUploader when uploading is the screen: many files, per-file status, retry on failure, thumbnails for images. It isn't a form field, though. It has no rules and doesn't take part in q-form validation, so for "an upload is required" you track the uploaded IDs yourself.
Why does my server receive the file under a strange field name? QUploader's field-name defaults to the file name. Set field-name="file".
How do I show upload progress with QFile? Send the files with XMLHttpRequest and read xhr.upload progress events, as in the helper above. fetch doesn't report upload progress.
Does accept stop users from uploading other file types? No. It filters the picker and QFile/QUploader reject files that don't match, but anyone can send any file to your route. Validate on the server.
The Form Layouts Block has four complete form cards built with Quasar; the first one is Free. Two of them are profile forms with an avatar preview and an "Upload Image" button. The button is styled but has no file logic, so pair it with the QFile pattern from this post and post the image to the same route.