Blog

Articles, updates, and insights from the FiraForm team.

HTML Form File Upload: How to Upload Files Without a Backend

By FiraForm Team
html forms file-upload static-sites tutorial

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-Type and Content-Disposition headers when serving files back
  • Consider using signed URLs with expiration for sensitive files

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

BackendMax per uploadNotes
Netlify Forms8 MBFixed across plans
FiraForm5 MB per file, 50 MB per form, 10 files per formPlus plan storage quota
FormspreePlan-dependentNo file uploads on free tier
BasinPlan-dependentVirus scanning included
Forminit25 MBAcross 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 / ConceptPurposeClient or Server
method="POST"Required for file dataBoth
enctype="multipart/form-data"Enables binary file encodingClient
accept=".pdf,.jpg"Filters file pickerClient only
multipleAllows selecting several filesClient only
FormData APIPackages files for fetch / XHRClient
Don’t set Content-Type headerBrowser must set the boundaryClient
MIME type / magic byte checkValidates actual file typeServer
File size limitPrevents abuseServer
Filename sanitizationPrevents path traversalServer

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:

  1. Always use enctype="multipart/form-data" for file uploads
  2. Never manually set the Content-Type header when using FormData
  3. Client-side validation is for UX, not security
  4. Server-side validation of type, size, and content is mandatory
  5. 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.