All resources
Static site forms

How to Accept File Uploads on an HTML Form (Without a Backend)

Accept résumés, screenshots and PDFs on a static site form: the one attribute everyone forgets, how to cap sizes before the upload starts, where the files are stored, and what each hosted form service actually allows.

The ShipMyForm team

· 6 min read

To accept file uploads on a static site form you need two things: enctype="multipart/form-data" on the <form> tag, and a backend that receives multipart bodies and stores the bytes somewhere. A static host has no server to take the POST, so the receiving end is either an endpoint you write or a hosted form backend. The missing enctype is the cause of nearly every "the file never arrived" report.

Full disclosure: ShipMyForm is our product. The markup and the security advice below work with any backend; the comparison table says where other services are the better pick.

Why a file input alone does nothing

Two separate things go wrong, and they look identical from the outside: the form submits, you get a success page, and no file.

The encoding. A <form> defaults to application/x-www-form-urlencoded, which can only carry text. Drop a file input into it and the browser sends the filename as a string value. The receiving end sees resume=cv.pdf and nothing else. There is no error, because nothing failed. You asked for text and got text.

The receiver. On GitHub Pages, Netlify, Cloudflare Pages or any S3 bucket there is no process listening for a POST. Without a backend the browser posts into the void and the host answers with a 405 or the page itself.

The markup that works

html
<form action="https://shipmyform.com/f/YOUR_FORM_ID"
      method="POST"
      enctype="multipart/form-data">

  <label for="name">Name</label>
  <input id="name" name="name" type="text" required autocomplete="name">

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required autocomplete="email">

  <label for="resume">Résumé (PDF or Word, max 10 MB)</label>
  <input id="resume" name="resume" type="file"
         accept=".pdf,.doc,.docx,application/pdf" required>

  <!-- Spam honeypot: hidden, must stay empty -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
         style="display:none">

  <button type="submit">Apply</button>
</form>

Three details in there earn their place. The accept attribute filters the file picker, so someone choosing a résumé is not scrolling past their photo library. The size is written into the label rather than left for the error message to reveal. And the honeypot still matters, because a form offering storage is a more attractive target than one offering an inbox.

For several files, either add multiple to one input:

html
<label for="shots">Screenshots (up to 5)</label>
<input id="shots" name="shots" type="file" accept="image/*" multiple>

…or use separate inputs with different name attributes when the files mean different things. id_front and id_back are easier to handle downstream than files[0] and files[1].

Cap the size before the upload starts

A 40 MB file on hotel wifi takes a minute to reject. Checking first costs nothing:

html
<script>
  const MAX_BYTES = 10 * 1024 * 1024; // keep in step with your plan's limit

  document.querySelector("form").addEventListener("submit", (event) => {
    const input = document.querySelector("#resume");
    const file = input.files[0];
    if (file && file.size > MAX_BYTES) {
      event.preventDefault();
      // Tell them the actual size, not just "too large".
      input.setCustomValidity(
        `That file is ${(file.size / 1048576).toFixed(1)} MB. The limit is 10 MB.`
      );
      input.reportValidity();
    }
  });

  // Clear the message when they pick a different file.
  document.querySelector("#resume").addEventListener("change", (e) => {
    e.target.setCustomValidity("");
  });
</script>
The client-side check is a courtesy, not a control:

Anything in the browser can be bypassed with a curl command. accept only filters a file picker — it does not stop a different type being posted, and a renamed .exe passes it happily. The limit that matters is the one the receiving end enforces, and it has to be enforced there whatever the page says.

Where the bytes actually end up

This is the part worth understanding before you collect anyone's CV.

A file should never be stored inside the submission record. ShipMyForm puts the bytes in object storage and keeps only a reference on the submission: id, original filename, size and MIME type. Anything rendering a submission then asks for a link rather than carrying the file around:

  • Email notifications get a signed download link valid for 7 days, because recipients open mail days later.
  • The dashboard serves files through an authorized route tied to your session.
  • Deleting the submission deletes the files, in storage as well as the database.

Uploads are not on a public URL, which matters more than it sounds. A predictable public path is how uploaded ID documents end up indexed by Google.

One honest limitation. Other connectors receive the file's metadata, not a link. A webhook is told that cv.pdf, 2.4 MB, application/pdf arrived and can fetch it through the API, but a Slack message or a Sheets row will show that metadata rather than a clickable attachment. If your workflow depends on the file landing in Slack, check that before committing.

What each service actually allows

Uploads are where the free tiers stop being equivalent, so the limits decide this more than the price does.

ServiceUploads on the free planMax per fileFiles per submissionUpload storage
ShipMyFormNo — Starter ($6/mo) and up10 MB Starter · 25 MB Pro5 Starter · 10 Pro2 GB · 25 GB
FormspreeNo — Personal ($10/mo) and up25 MB51 GB Personal · 5 GB Professional
BasinYesNot publishedNot published100 MB free · 500 MB Starter ($12.50/mo)
Web3FormsNo — Pro only5 MB with the built-in uploaderNot publishedNot published
Netlify FormsYes8 MB for the whole submission1 per fieldCounted as plan usage

Where the others win. Formspree allows 25 MB per file on its cheapest upload plan, where ours allows 10 MB. If you routinely take large design files, that is the straightforward reason to pick them. Netlify Forms is free and unlimited on current credit-based plans, so if your site is already on Netlify and 8 MB per submission is enough, the cheapest correct answer is the one you already have. Basin accepts uploads on its free tier at all, which none of the rest do.

One test case: a 12 MB portfolio PDF

Pricing tables are abstract, so here is a single file against all five: a 12 MB PDF, the size a designer's portfolio actually reaches.

ServiceA 12 MB PDF
ShipMyForm StarterRejected, over the 10 MB per-file cap
ShipMyForm ProAccepted (25 MB)
Formspree PersonalAccepted (25 MB)
Web3Forms ProRejected with the built-in uploader (5 MB)
Netlify FormsRejected, the whole submission is capped at 8 MB

Two of five accept it at the cheapest upload tier, and ours is one of the three that does not. If "designers send portfolios" describes your form, that single number decides your choice, which is the point of checking it against a real file rather than a feature list.

Verify before you commit:

Figures read from each vendor's own pages on 1 October 2026: Formspree's plans page, Basin's pricing page, the Web3Forms file-attachments docs, and Netlify's Forms setup and billing docs. Limits and prices move. Basin publishes a storage allowance but no per-file size or per-submission count, and Web3Forms documents a per-file size but no storage allowance — those cells say "not published" rather than carrying a guess. Test with your largest real file before you promise a size to users.

Uploading with fetch(), and a progress bar

For an upload without a page reload, pass the FormData straight through:

js
const form = document.querySelector("form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const body = new FormData(form); // files included, nothing else to do

  const res = await fetch(form.action, {
    method: "POST",
    body,
    headers: { Accept: "application/json" },
    // No Content-Type here — see below.
  });

  if (res.ok) form.replaceWith(Object.assign(document.createElement("p"), {
    textContent: "Thanks — we have your application.",
  }));
});

Never set Content-Type yourself when sending FormData. The browser has to add a generated multipart/form-data; boundary=… value, and hand-writing the header strips the boundary, so the server receives a body it cannot parse and the files vanish. This is the second most common upload bug after the missing enctype.

fetch cannot report upload progress, so a progress bar still needs XMLHttpRequest:

js
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
  if (e.lengthComputable) {
    bar.value = (e.loaded / e.total) * 100; // <progress max="100">
  }
});
xhr.open("POST", form.action);
xhr.setRequestHeader("Accept", "application/json");
xhr.send(new FormData(form)); // again: no Content-Type

Worth the extra few lines for anything above a couple of megabytes — without feedback, people submit twice.

Four things to get right before you collect real files

  1. Treat the filename as hostile. It is attacker-controlled text that will end up in an email, a filesystem path and a download header. Both ../../etc/passwd and a 400-character name are ordinary inputs. Store under an id you generate; keep the original name as a label only.
  2. Never serve uploads from your own origin. An HTML or SVG file served from your domain runs JavaScript with your site's cookies. Serve downloads from a separate host or an authorized route that forces a download rather than rendering.
  3. Do not trust the MIME type. The browser sends what it guesses, and anyone posting directly sends whatever they like. If the distinction matters, check the file's actual signature server-side.
  4. Decide the retention up front. Uploads are the part of a submission most likely to be personal: CVs, ID photos, screenshots with account details. Keeping them "just in case" is a liability with a storage bill attached. Set the retention window deliberately and make sure deleting a submission really deletes the file.

When a form backend is the wrong tool

If files are the point of the product rather than an attachment to a message (a client portal, video review, anything over a few hundred megabytes) use direct-to-storage uploads with presigned URLs instead. A form backend is built for a submission that happens to carry a file; it is not an upload service, and you will hit per-file caps designed around that assumption.

The dividing line is roughly this. A résumé on an application form, a screenshot on a bug report or a brief on a quote request: a form backend. A 2 GB video: presigned S3.

Frequently asked questions

Can an HTML form upload a file without any backend?
No. The browser can read and send the file, but something has to receive and store the bytes, and a static host (GitHub Pages, Netlify, Cloudflare Pages) has no server to do that. You either write an endpoint yourself or point the form at a hosted form backend that accepts multipart bodies.
Why does my form submit but the file never arrives?
Almost always a missing enctype="multipart/form-data" on the <form> tag. Without it the browser URL-encodes the form and sends only the file's name as a text value, so the receiving end gets "cv.pdf" as a string and no file at all. Adding that one attribute fixes it.
What is the maximum file size for a form upload?
There is no limit in HTML — it is set by whatever receives the POST. As of October 2026: ShipMyForm allows 10 MB per file on Starter and 25 MB on Pro, Formspree 25 MB per file on paid plans, Web3Forms 5 MB with its built-in uploader, and Netlify Forms caps the entire submission at 8 MB. Check the current figure before you promise a size to users.
Can one form accept multiple files?
Yes: add the multiple attribute to a single file input, or use several file inputs with different name attributes. The per-submission count is capped by your backend — ShipMyForm allows 5 files on Starter and 10 on Pro, Formspree allows 5, and Netlify Forms takes only one file per field.
Should I validate the file size in JavaScript or on the server?
Both, for different reasons. The client-side check is a courtesy — it fails fast instead of making someone wait through a doomed upload. The server-side cap is the real one, because anything in the browser can be bypassed. Never rely on the accept attribute or a JavaScript check as a security control.
Where are uploaded files stored, and who can download them?
With ShipMyForm the bytes go to object storage, never into the submission record itself, and are reachable only through an authorized route: a signed link in your email notification (valid 7 days) or your dashboard session. The files are not on a public URL, and deleting the submission deletes them.
Do uploads work when I submit the form with fetch() instead of a page reload?
Yes. Pass the FormData object straight to fetch as the body and do not set a Content-Type header — the browser sets the multipart boundary itself. Setting it manually by hand is the usual reason a fetch upload arrives empty. For an upload progress bar you need XMLHttpRequest, as fetch cannot report upload progress.
Can uploaded files be forwarded to Slack, Sheets or a webhook?
Partly. Email notifications and the dashboard render real download links. Other connectors receive the file's metadata — id, filename, size and MIME type — rather than a link, so a webhook knows a 2.4 MB PDF called cv.pdf arrived and can fetch it through the API, but Slack and Sheets will show the metadata rather than a clickable attachment.

Related guides