Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 13, 2026

Quasar File Upload: QFile vs QUploader

Quasar file upload two ways: QFile in a form with your own progress bar, or QUploader with its built-in queue, both posting to one Nuxt server route.
Quasar
Nuxt
Forms
Uploads

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.

QFile vs QUploader at a glance

q-fileq-uploader
What it isA field, like q-input, whose model is a File or an array of filesA card with a header, a file list and upload buttons
Sends the filesNo. You write the requestYes, with XMLHttpRequest, to url
Upload progressYours to buildPer file and in total, in the header
Validationrules, and it takes part in q-formaccept, max-file-size, max-files, filter
Drag and dropYesYes
Best forA file that belongs to a form: an avatar, an attachment on a ticketA 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.

The Nuxt server route that receives the files

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:

  • Validate every file before writing any. A batch with one bad file fails as a whole instead of leaving half of it on disk.
  • Never use the client's filename on disk. The stored name is a UUID, and the extension comes from the allow-list, not from file.filename. The original name is returned as data only.
  • The 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.

Quasar file upload with QFile and a progress bar

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:

  • With 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.
  • The #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: queue, progress and retries built in

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.
  • One request per file. By default each file gets its own XHR. Add batch to send them all in one request, which the server above also accepts.
  • Success means a status below 400. Anything else marks the file as failed, emits @failed and puts the file back in the queue, so the upload button retries it.
  • Progress comes from xhr.upload events, per file and in the header. auto-upload starts as soon as files are added.

Sending auth headers or extra fields

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.

Which one to pick

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.

Common questions about Quasar file upload

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.

Next step

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.