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.
