HTMX File Uploads

File uploads are a common requirement in web applications — profile pictures, document attachments, product images, and more. HTMX handles file uploads by encoding form data as multipart/form-data, which is exactly the encoding browsers use for native file upload forms. You get a smooth, no-reload upload experience with minimal code.

How File Uploads Differ From Regular Forms

A regular form sends data as URL-encoded text. A file upload form sends data as multipart/form-data — a format that can carry binary content like images, PDFs, and videos. HTMX detects the presence of a file input and automatically switches to multipart/form-data encoding for that request.

Basic File Upload Form

<form
  hx-post="/upload"
  hx-target="#upload-result"
  hx-encoding="multipart/form-data">

  <label>Choose a file</label>
  <input type="file" name="document">

  <button type="submit">
    Upload
    <span class="htmx-indicator">Uploading...</span>
  </button>
</form>

<div id="upload-result"></div>

The hx-encoding attribute explicitly sets the encoding. Although HTMX detects file inputs automatically, setting it explicitly makes the intent clear and avoids surprises with some server frameworks.

Accepting Specific File Types

<!-- Images only -->
<input type="file" name="avatar" accept="image/*">

<!-- PDF only -->
<input type="file" name="report" accept=".pdf">

<!-- Multiple file types -->
<input type="file" name="attachment" accept=".pdf,.doc,.docx">

<!-- Multiple files at once -->
<input type="file" name="photos" multiple accept="image/*">

Server-Side Handling

Flask (Python)

import os
from werkzeug.utils import secure_filename

UPLOAD_FOLDER = '/var/uploads'
ALLOWED = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files.get('document')
    if not file or file.filename == '':
        return '<p style="color:red">No file selected.</p>'

    ext = file.filename.rsplit('.', 1)[-1].lower()
    if ext not in ALLOWED:
        return '<p style="color:red">File type not allowed.</p>'

    filename = secure_filename(file.filename)
    file.save(os.path.join(UPLOAD_FOLDER, filename))
    return f'<p style="color:green">Uploaded: {filename}</p>'

Node.js (Multer + Express)

const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('document'), (req, res) => {
    if (!req.file) {
        return res.send('<p style="color:red">No file received.</p>');
    }
    res.send(`<p style="color:green">Uploaded: ${req.file.originalname}</p>`);
});

Upload Progress Indicator

HTMX's built-in htmx-indicator works for uploads but does not show percentage progress. For a real progress bar, listen to the htmx:xhr:progress event:

<form
  hx-post="/upload"
  hx-target="#result"
  hx-encoding="multipart/form-data"
  hx-on:htmx:xhr:progress="updateProgress(event)">

  <input type="file" name="file">
  <button type="submit">Upload</button>
</form>

<progress id="upload-progress" value="0" max="100"></progress>
<div id="result"></div>

<script>
function updateProgress(event) {
    if (event.detail.lengthComputable) {
        const pct = Math.round((event.detail.loaded / event.detail.total) * 100);
        document.getElementById('upload-progress').value = pct;
    }
}
</script>
  Upload progress diagram:

  [ Choose File: report.pdf ] [ Upload ]

  Uploading:  ████████░░░░░░░░  50%
              ████████████░░░░  75%
              ████████████████ 100%

  Result: Uploaded: report.pdf ✓

Drag and Drop Upload

Drag-and-drop works by using JavaScript to handle the drop event and then calling HTMX's JavaScript API to trigger the upload programmatically. This is one of the few cases where a small amount of JavaScript is necessary alongside HTMX:

<div id="drop-zone"
     style="border:2px dashed #ccc; padding:40px; text-align:center">
  Drop files here
</div>

<div id="drop-result"></div>

<script>
const zone = document.getElementById('drop-zone');

zone.addEventListener('dragover', e => {
    e.preventDefault();
    zone.style.background = '#f0f8ff';
});

zone.addEventListener('dragleave', () => {
    zone.style.background = '';
});

zone.addEventListener('drop', e => {
    e.preventDefault();
    zone.style.background = '';

    const formData = new FormData();
    formData.append('document', e.dataTransfer.files[0]);

    htmx.ajax('POST', '/upload', {
        values: formData,
        target: '#drop-result',
        swap: 'innerHTML'
    });
});
</script>

Client-Side File Validation

Always validate file type and size on both the client and the server. Client-side checks give instant feedback; server-side checks enforce security:

<input type="file" name="image" id="img-input" accept="image/*">
<span id="file-error"></span>

<script>
document.getElementById('img-input').addEventListener('change', function() {
    const file = this.files[0];
    const maxMB = 5;
    const errEl = document.getElementById('file-error');
    if (file && file.size > maxMB * 1024 * 1024) {
        errEl.textContent = 'File must be under 5 MB.';
        errEl.style.color = 'red';
        this.value = '';
    } else {
        errEl.textContent = '';
    }
});
</script>

Security Checklist for File Uploads

  • Validate file extension AND MIME type on the server — never trust the browser.
  • Use a secure filename function to strip path characters from uploaded filenames.
  • Store uploaded files outside the web root, or in a cloud storage service.
  • Set a maximum file size limit on the server, not just the client.
  • Scan uploaded files for malware in high-security applications.
  • Never execute uploaded files as code.

Key Takeaway

HTMX file uploads use the same form submission pattern as regular forms, with hx-encoding="multipart/form-data" added. The server receives the file through standard file-handling libraries. The result is a smooth, no-reload upload experience. Validate file type and size on both client and server. For progress bars, listen to the htmx:xhr:progress event. For drag and drop, use a small JavaScript helper alongside HTMX's ajax API.

Leave a Comment

Your email address will not be published. Required fields are marked *