HTML Form File Upload: How to Upload Files Without a Backend
HTML Form File Upload: How to Upload Files Without a Backend
File uploads are one of those features that seem simple until you try to build them. You need users to send you a resume, a photo, a spreadsheet — but your site is static. There’s no server-side code running to receive the file. So what do you do?
The good news: the HTML and browser side of file uploads is straightforward once you understand a few key concepts. The backend processing is a separate concern, and for static sites there are solid options that don’t require you to spin up a server.
This guide walks through the complete picture — from the HTML attributes that make uploads work, to JavaScript submission patterns, to the security considerations most tutorials skip.
When Do You Need File Uploads?
Before diving into code, it’s worth knowing where file uploads actually show up:
- Job applications — resume and cover letter uploads
- Support forms — screenshot or log file attachments
- Portfolio submissions — images, PDFs, design files
- Content management — user-generated media
- Document signing — uploaded signatures or marked-up contracts
- Bug reports — error screenshots, reproduction files
In each case, the pattern is the same: the user selects a file in their browser, the browser packages it into the request, and something on the other end receives it.
The HTML You Actually Need
A file upload form has three critical pieces. Get these right and the browser handles the rest.
1. method="POST"
File uploads always use POST. GET requests have URL length limits and aren’t designed for binary data.
2. enctype="multipart/form-data"
This is the attribute most people forget or misunderstand. Without it, the browser sends the form as URL-encoded text — which strips out the file entirely. The multipart/form-data encoding tells the browser to package each form field into a separate part of the request body, with the file content intact.
3. A labeled <input type="file">
The input needs a name attribute (the server uses this to identify the file) and ideally an accept attribute to guide the user toward the right file types.
Here’s the minimal HTML:
<form
id="upload-form"
action="YOUR_FORM_ENDPOINT"
method="POST"
enctype="multipart/form-data"
>
<div>
<label for="document">Upload your file</label>
<input
type="file"
id="document"
name="document"
accept=".pdf,.doc,.docx,.jpg,.png"
/>
</div>
<button type="submit">Upload</button>
</form>
That’s it. The enctype attribute is doing the heavy lifting here — it’s what makes the file actually get sent.
Multiple File Uploads
Need more than one file? Add the multiple attribute:
<input
type="file"
id="documents"
name="documents"
multiple
accept=".pdf,.doc,.docx,.jpg,.png"
/>
The browser will let users select multiple files in the file picker. All selected files get sent under the same field name.
Restricting File Types
The accept attribute is a hint to the browser — it filters the file picker dialog but doesn’t enforce validation. Common patterns:
<!-- Images only -->
<input type="file" name="photo" accept="image/*" />
<!-- Specific document types -->
<input type="file" name="resume" accept=".pdf,.doc,.docx" />
<!-- Multiple media types -->
<input type="file" name="media" accept="image/*,video/*,.pdf" />
Important: The accept attribute is client-side only. Server-side validation of file types is always mandatory (more on this in the security section).
JavaScript Submission with FormData
Native HTML form submission works, but it causes a full page reload. For a smoother experience, use JavaScript’s FormData API to submit the file via fetch.
const form = document.getElementById('upload-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
button.textContent = 'Uploading...';
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
if (response.ok) {
form.innerHTML = '<p>File uploaded successfully!</p>';
} else {
throw new Error('Upload failed');
}
} catch (error) {
alert('Something went wrong. Please try again.');
button.disabled = false;
button.textContent = 'Upload';
}
});
Notice what’s not in this code: a Content-Type header. When you pass a FormData object as the request body, the browser automatically sets Content-Type: multipart/form-data with the correct boundary string. If you manually set the header, you’ll break the boundary and the server won’t be able to parse the request. Let the browser handle it.
Building FormData Manually
Sometimes you need to append files programmatically rather than relying on a form element — for example, with drag-and-drop:
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
const response = await fetch('YOUR_FORM_ENDPOINT', {
method: 'POST',
body: formData,
});
// Handle response...
});
You can also mix file fields with regular text fields:
const formData = new FormData();
formData.append('name', 'Jane Doe');
formData.append('email', '[email protected]');
formData.append('resume', fileInput.files[0]);
Single File vs. Multiple Files
The submission pattern differs slightly depending on whether you’re handling one file or many.
Single File
One <input type="file"> with one selected file. The server receives a single file under the field name you specified:
<input type="file" name="resume" />
Multiple Files, Same Field
One <input type="file" multiple> with several selected files. All files arrive under the same field name as an array:
<input type="file" name="attachments" multiple />
const formData = new FormData(form);
// formData.getAll('attachments') returns all selected files
Multiple File Inputs
If you have distinct upload slots (e.g., “profile photo” and “cover image”), use separate inputs with different names:
<input type="file" name="profile_photo" accept="image/*" />
<input type="file" name="cover_image" accept="image/*" />
This keeps the files logically separated on the server side.
Client-Side Validation and UX
Client-side validation isn’t security — it’s UX. It prevents accidental mistakes and gives users immediate feedback. Server-side validation is still mandatory.
File Size Limits
There’s no HTML attribute for maximum file size, so you check it in JavaScript:
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (file && file.size > MAX_SIZE) {
alert('File is too large. Maximum size is 5 MB.');
fileInput.value = '';
}
});
File Type Checking
Double-check the type beyond what accept provides:
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (file && !ALLOWED_TYPES.includes(file.type)) {
alert('Only PDF, JPG, and PNG files are accepted.');
fileInput.value = '';
}
});
Visual Upload Feedback
A basic upload progress indicator improves the experience significantly:
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressText.textContent = `Uploading... ${percent}%`;
}
});
xhr.open('POST', form.action);
xhr.send(formData);
Note: XMLHttpRequest supports upload progress events. The modern fetch API does not. If upload progress matters to your users, XMLHttpRequest is the practical choice.
Security and Privacy Essentials
File uploads are one of the highest-risk form interactions. Treat every uploaded file as untrusted.
Server-Side Validation Is Non-Negotiable
Client-side checks are for UX only. The server must independently validate:
- File type — check the MIME type and, ideally, the file signature (magic bytes), not just the extension
- File size — enforce a hard limit on the server regardless of client-side checks
- File content — scan for malware if accepting uploads from untrusted users
- Filename sanitization — never trust the filename the client sends; strip path components and rename if needed
Storage Considerations
- Store uploaded files outside the web root if possible
- Use randomized filenames to prevent path traversal and guessing
- Set appropriate
Content-TypeandContent-Dispositionheaders when serving files back - Consider using signed URLs with expiration for sensitive files
Download Links Over Email Attachments
For larger files, prefer generating a download link over emailing the file as an attachment. Email attachments have size limits (typically 10–25 MB), get caught in spam filters, and bloat mailboxes. A download link is more reliable and gives you control over access and expiration.
Rate Limiting
Without rate limiting, your upload endpoint becomes a target for abuse. Even a simple approach helps — limit uploads to a reasonable number per IP per hour.
Backend Options for Static Sites
Since static sites can’t process uploads on their own, you need an external backend. Here are the main options:
1. Serverless Functions
Deploy a function on Cloudflare Workers, AWS Lambda, or Vercel Functions that receives the multipart/form-data body, parses it, and stores the file (often paired with object storage like S3, R2, or GCS). This gives you full control over validation, transformation, and storage layout, but requires writing and maintaining backend code.
A popular configuration on Cloudflare is a Worker paired with R2 (Cloudflare’s object storage) — edge deployment, generous free tiers, and no cold starts. The same pattern works with Lambda + S3 or Vercel Functions + Vercel Blob.
2. Form Backend Services
Services like FiraForm, Formspree, or Basin handle the entire upload pipeline — you point your form at their endpoint and they receive, validate, store, and notify you. No backend code to write.
What they typically handle for you:
- File reception and storage
- Per-form size limit enforcement (configurable on most services)
- Allowed MIME type validation
- File delivery to your inbox as attachment or webhook payload
- File metadata capture (filename, size, type)
This is the lowest-friction path if you want uploads handled reliably without standing up infrastructure.
3. Cloud Storage Direct Upload
For large files or media-heavy workflows, you can upload directly to cloud storage (S3, GCS, R2) using pre-signed URLs. The client requests a signed URL from your API, then uploads directly to storage. The server never touches the file bytes — it only hands out the URL.
This pattern is harder to set up (you need an endpoint to mint signed URLs and a CORS policy on the bucket) but scales to large files efficiently and bypasses any per-request limits imposed by form backends or functions.
File-Size Limits
| Backend | Max per upload | Notes |
|---|---|---|
| Netlify Forms | 8 MB | Fixed across plans |
| FiraForm | 5 MB per file, 50 MB per form, 10 files per form | Plus plan storage quota |
| Formspree | Plan-dependent | No file uploads on free tier |
| Basin | Plan-dependent | Virus scanning included |
| Forminit | 25 MB | Across plans |
Most form backend services also have a global monthly storage or bandwidth cap that matters as much as per-file limits — check the service’s pricing page for the full picture.
Putting It All Together
Here’s a complete, production-ready upload form that combines everything covered in this guide:
<form
id="upload-form"
action="YOUR_FORM_ENDPOINT"
method="POST"
enctype="multipart/form-data"
>
<div>
<label for="name">Your Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="file">Upload File (PDF, JPG, PNG — max 5 MB)</label>
<input
type="file"
id="file"
name="file"
accept=".pdf,.jpg,.jpeg,.png"
required
/>
</div>
<!-- Honeypot field for spam protection -->
<input
type="text"
name="_gotcha"
style="display: none"
tabindex="-1"
autocomplete="off"
/>
<button type="submit">Submit</button>
</form>
<script>
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file');
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
];
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > MAX_SIZE) {
alert('File is too large. Maximum size is 5 MB.');
fileInput.value = '';
return;
}
if (!ALLOWED_TYPES.includes(file.type)) {
alert('Only PDF, JPG, and PNG files are accepted.');
fileInput.value = '';
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
button.textContent = 'Uploading...';
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
if (response.ok) {
form.innerHTML =
'<p>Thank you! Your file has been uploaded.</p>';
} else {
throw new Error('Upload failed');
}
} catch (error) {
alert('Something went wrong. Please try again.');
button.disabled = false;
button.textContent = 'Submit';
}
});
</script>
Quick Reference
| Attribute / Concept | Purpose | Client or Server |
|---|---|---|
method="POST" | Required for file data | Both |
enctype="multipart/form-data" | Enables binary file encoding | Client |
accept=".pdf,.jpg" | Filters file picker | Client only |
multiple | Allows selecting several files | Client only |
FormData API | Packages files for fetch / XHR | Client |
Don’t set Content-Type header | Browser must set the boundary | Client |
| MIME type / magic byte check | Validates actual file type | Server |
| File size limit | Prevents abuse | Server |
| Filename sanitization | Prevents path traversal | Server |
Conclusion
File uploads in HTML forms come down to a few things: the right enctype, a properly named file input, and a backend that knows what to do with the data. The browser handles the multipart encoding for you — your job is to wire it up correctly and validate on the server side.
For static sites, the backend question is the real challenge. Serverless functions give you control; form backend services give you speed; cloud storage gives you scale. Pick the one that matches your project’s needs.
The key takeaways:
- Always use
enctype="multipart/form-data"for file uploads - Never manually set the
Content-Typeheader when usingFormData - Client-side validation is for UX, not security
- Server-side validation of type, size, and content is mandatory
- Prefer download links over email attachments for larger files
Build the form, point it at a backend, and let the browser do the heavy lifting.
Need Forms For Your Static Site?
FiraForm is the headless form backend built specifically for static sites. No backend code needed—just point your forms to our endpoint and we'll handle submissions, validation, notifications, and more.
Free tier available • No credit card required
Have questions about handling file uploads on a static site? Reach out — happy to help you figure out what fits your workflow.