The HTML basics most tutorials skip
File uploads in HTML look simple until you actually ship one. The two attributes that matter are on the <form> tag, not the input: method="POST" and enctype="multipart/form-data". Miss the enctype and the browser ships just the filename as a URL-encoded string. Miss the method and the file lands in the URL bar, which fails the moment the file is bigger than the URL length limit.
The minimum viable upload form looks like this:
<form action="https://splitforms.com/api/submit"
method="POST"
enctype="multipart/form-data">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label>Your name <input type="text" name="name" required /></label>
<label>Email <input type="email" name="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<label>Attach a file
<input type="file" name="attachment" />
</label>
<button type="submit">Send</button>
</form>That's it. No JavaScript. The browser packages the text fields and the binary file into one multipart payload and POSTs it. If you've never used a free HTML contact form with splitforms before, this is the exact pattern — you just bolt on the file input.
One thing most tutorials don't explain: the multipart format itself. When the browser POSTs this form, the body looks like a series of parts separated by a random boundary token. Each part has its own headers — Content-Disposition for the field name, Content-Type for files. You almost never need to read the raw multipart format, but knowing it exists explains why "just JSON-encode it" doesn't work for uploads. Binary file bytes don't survive JSON encoding without base64, and base64 inflates every file by 33%. Multipart skips that.
Multiple files and the accept attribute
Two attributes let you control what users pick. multiple turns one input into a multi-file picker, and accept filters the file dialog by extension or MIME type:
<!-- Resume uploads (PDF or Word) -->
<input type="file" name="resume" accept=".pdf,.doc,.docx" />
<!-- Image gallery (any image type, multiple files) -->
<input type="file" name="photos" accept="image/*" multiple />
<!-- Specific MIME types -->
<input type="file" name="design" accept="image/png,image/jpeg,application/pdf" />Two things to know about accept. First, it's a hint, not a guarantee — the OS file picker uses it to filter the default view, but the user can always switch to "All files" and pick something else. Second, the syntax is finicky: extensions need the dot prefix, MIME types don't, and you can mix them. image/* is the most useful wildcard because it covers HEIC, AVIF, and WebP without listing every modern format.
If you set multiple, the form sends an array under the same field name. Your backend has to know to expect photos[] rather than a single photos value.
The splitforms upload endpoint
The same URL you already POST text submissions to — https://splitforms.com/api/submit — accepts file uploads when the request is multipart. There's no separate "upload" endpoint. The server inspects the Content-Type header, and if it starts with multipart/form-data, it streams the parts and retains file fields when Storage is connected.
What you get when splitforms Storage is connected:
- Up to 5 files per submission, 10 MB per file.
- Private object storage; files are stored under generated keys, with original filenames preserved as metadata.
- Signed download links so notification and webhook workflows can reference files without making the bucket public.
- Server-side safety checks including executable/script-like extension blocking and content-type sniffing.
- Webhook payloads can include stored-file metadata so downstream services can copy files into your own storage.
File uploads are not a separate endpoint and do not require custom client-side signing code, but you should connect Storage before publishing an upload form. Text submissions keep working without Storage; files are simply not retained. See the side-by-side at splitforms vs Formspree.
Get an access key at splitforms.com/login — no credit card, the free tier covers 1,000 submissions/month forever.
Server-side validation (the part you can't skip)
Anything the browser tells you about a file is user input. The filename can contain path traversal sequences (../../etc/passwd), the Content-Type can be forged, the extension can be wrong. You have to re-validate on the server every time. Three layers:
- Size check. Reject anything over your limit before you finish reading the stream. Don't buffer the whole thing first.
- Magic-byte verification. Read the first 8–16 bytes and match against the real file signature. PDFs start with
%PDF-, PNGs start with\x89PNG\r\n, JPEGs start with\xff\xd8\xff. Libraries likefile-type(Node) orpython-magicdo this for you. - Filename sanitization. Strip directory separators, null bytes, and control characters. Don't use the user-supplied filename as the storage path — use a UUID and keep the original name in metadata only.
// Node.js example with file-type
import { fileTypeFromBuffer } from "file-type";
const result = await fileTypeFromBuffer(uploadedBuffer);
if (!result) throw new Error("Unknown file type");
if (!["pdf", "png", "jpg"].includes(result.ext)) {
throw new Error("Disallowed type: " + result.ext);
}
// Sanitize filename for display only
const safeName = original
.replace(/[\/\\:*?"<>|\x00-\x1f]/g, "_")
.slice(0, 200);splitforms applies all three layers by default, but if you're self-hosting or building a hybrid setup with your own backend, you own this code.
Antivirus scanning and S3 storage
A public-facing form that accepts uploads is, by definition, an inbound malware funnel. Every upload should go through a virus scanner before it touches your inbox or any user-accessible storage. The de facto open-source choice is ClamAV — it's free, runs as a daemon, and updates signatures every hour.
The pattern is: receive upload to temp storage → validate and scan → if clean, move to private object storage → if unsafe, delete and notify. At minimum, block executable/script-like extensions, sniff content type, and avoid forwarding raw uploads directly to an inbox.
For S3 storage, the things that matter:
- Bucket is private. No public-read ACL. Access happens through signed URLs only.
- Signed URLs expire. 7 days is a reasonable default for email delivery; 1 hour for webhook delivery to downstream APIs.
- Lifecycle policy. Auto-delete after your retention period. Saves cost and limits exposure.
- Server-side encryption. SSE-S3 at minimum, SSE-KMS if you have compliance requirements.
Email attachment delivery vs link delivery
You have two choices for how files reach your inbox, and the right answer depends on file size and downstream tooling.
Inline attachments are familiar — the file shows up clipped to the email like any other attachment. Works in every email client, downloadable with one click. Downside: 25 MB cap (Gmail, Outlook), every recipient gets a copy in their mailbox, and large attachments slow down inbox sync.
Signed download links embed a clickable URL in the email body. Files stay in private object storage, recipients download on demand. Pros: inbox stays light, the same link works from a phone or laptop. Cons: link expires, no offline access once it's gone.
| Use case | Recommended | Why |
|---|---|---|
| Resumes, contact form PDFs | Attachment | Small, want them archived in inbox |
| Design files, video uploads | Link | Often >25 MB, inbox bloat |
| Customer support screenshots | Attachment | Quick to view inline |
| Photo gallery submissions | Link | Multiple files, prefer S3 browse |
| Legal / signed PDFs | Both | Inline for quick read, link for the canonical copy |
For splitforms, plan around signed links for retained files. It keeps notifications light and avoids mailbox attachment limits.
Security pitfalls that bite people
I've seen the same five mistakes on review after review:
- Trusting the Content-Type header. The browser sends whatever the OS tells it, and the OS guesses from the extension. A file renamed from
shell.phptoshell.jpgarrives withimage/jpeg. Sniff the magic bytes. - Using the user's filename as a storage path.
uploads/${originalName}is a path traversal waiting to happen. Use a UUID. Always. - Serving uploads from your main domain. If you ever serve user-uploaded files, do it from a separate domain (like
files.example-cdn.com) to dodge same-origin attacks. Even better: serve from S3 directly via signed URLs. - No size cap on the input stream. If your handler reads the whole body into memory before checking size, an attacker uploads a 10 GB file and crashes your process. Set the cap at the proxy or web server level (NGINX
client_max_body_size, Vercel function config). - Skipping virus scanning "because it's just images". Image parsers have CVEs constantly. Scan everything.
Pair this with general anti-spam — see honeypot vs reCAPTCHA and the complete spam protection guide — because bots love uploading their friends to your S3 bucket.
Drag-and-drop UI with vanilla JS
The native file input works but looks like 2003. Drag-and-drop is one event handler away. The trick is binding to drop on a styled div and copying the dropped files into a hidden file input's FileList.
<form id="f" action="https://splitforms.com/api/submit" method="POST"
enctype="multipart/form-data">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="email" name="email" required />
<div id="drop" style="border:2px dashed #888; padding:32px; text-align:center;">
Drop files here, or
<input type="file" name="attachment" id="fi" multiple />
</div>
<button type="submit">Send</button>
</form>
<script>
const drop = document.getElementById("drop");
const fi = document.getElementById("fi");
["dragenter","dragover"].forEach(e =>
drop.addEventListener(e, ev => {
ev.preventDefault();
drop.style.background = "#eef";
})
);
["dragleave","drop"].forEach(e =>
drop.addEventListener(e, ev => {
ev.preventDefault();
drop.style.background = "";
})
);
drop.addEventListener("drop", ev => {
fi.files = ev.dataTransfer.files;
});
</script>That's the whole pattern. The dropped FileList is assigned directly to the input — when the form submits, the browser uploads those files as if the user had picked them through the dialog. No FormData fiddling required.
Framework versions (React, Vue, Next.js)
If you're using a framework, the same multipart form works — you just have to be careful not to intercept the submit. The default React mistake is onSubmit=(e) => e.preventDefault() followed by fetch(url, { body: JSON.stringify(values) }). That throws away the file. Use FormData instead:
// React + fetch (preserves files)
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: data, // do NOT set Content-Type — fetch sets the boundary
});
if (res.ok) form.reset();
}Two rules: build the body with new FormData(formElement), and don't manually set Content-Type — fetch needs to set the multipart boundary itself. If you set it manually, the boundary token won't match and the server will reject the body.
Vue is identical — bind a ref to the form element and pass it to FormData. For Vue specifics see the Vue form backend guide; for Next.js (App Router, including Server Actions caveats) see the Next.js guide and the Server Actions vs form backend comparison. React-specific patterns live in the React form backend guide.
Troubleshooting the three errors you'll hit
413 Request Entity Too Large
Someone in the request chain rejected the body before it reached splitforms. Cloudflare free caps at 100 MB, NGINX defaults to 1 MB, Vercel functions cap at 4.5 MB. If you proxy through any of these, raise the cap or post directly to splitforms.com/api/submit. splitforms' current cap is 10 MB per file.
CORS preflight failing on multipart
Multipart uploads trigger a preflight OPTIONS request. If you see "blocked by CORS policy" on uploads only, the OPTIONS handler is missing or doesn't echo back Access-Control-Allow-Headers. splitforms handles preflight automatically — you only hit this if you're proxying through your own server. Posting directly to splitforms from the browser works out of the box.
Mobile camera capture not opening
The capture attribute only works on real mobile browsers (iOS Safari, Android Chrome). It does nothing in desktop Chrome or in iOS in-app browsers (Instagram, TikTok web view). If you must support in-app browsers, fall back to a regular file picker and let the user choose "Take Photo" from the OS sheet — it's an extra tap but it always works.
Upload progress shows 100% but the server returns nothing
This one trips up first-time uploaders. The browser progress bar measures how much of the body it has handed to the OS network stack — not how much the server has received. With a slow uplink, your progress bar can sit at 100% for 10+ seconds while the file is still in flight. Don't time out the fetch promise on a fast timer; larger uploads on a cellular connection can take 30 seconds. Use a longer timeout (60s+) and show a spinner after the visual progress hits 100% so the user knows you're still waiting on the server.
For other deliverability and submission issues, see contact form not working and CORS error form submission fix.
Next steps
- Grab an access key: splitforms.com/login — free tier covers 1,000 submissions/month.
- Upload spec, multipart contract, and signed-URL TTLs: /docs and /api-reference.
- Already on Formspree? Files migrate cleanly — see the 5-minute migration guide.
- Compare backends: best free form backend services 2026.
- Plan and storage retention questions: /faq.
- Browse more guides: splitforms blog.
FAQ
What's the maximum file size I can attach to a form submission?
Browsers will technically upload anything you give them, but practical limits are dictated by your form backend. With splitforms Storage connected, the current limit is 5 files per submission and 10 MB per file. If you need bigger files — videos, design files, large PDFs — use a dedicated upload flow or copy signed links into your own object storage.
Do I need to set enctype='multipart/form-data' on the form?
Yes, every time. Without it the browser URL-encodes the file's filename instead of uploading the bytes. The form looks like it submits, but the server receives a string like 'file=resume.pdf', not the actual file. This is the single most common file upload bug — paste the enctype on the form tag and you're done.
Should I rely on the accept attribute for validation?
No. The accept='.pdf,.docx' attribute is a UI hint that filters the OS file picker — a determined user can pick 'All files' and upload anything. Always re-validate on the server using MIME sniffing (magic bytes), not the filename extension or the Content-Type header the browser sent. splitforms blocks executable/script-like extensions and sniffs content type server-side.
How does splitforms deliver uploaded files — link or attachment?
With Storage connected, uploaded files are retained privately and exposed through signed download links. The current signed-link window is 7 days. Webhook payloads can include stored-file metadata so your own backend can copy files into S3, R2, Google Drive, or a CRM.
Why am I getting a 413 Request Entity Too Large error?
Your upload exceeded a limit somewhere in the chain. The chain is: browser → CDN/proxy → app server → form backend. Each can have its own cap. Cloudflare's free plan caps body size at 100 MB. Vercel functions cap at 4.5 MB. NGINX defaults to 1 MB until you raise client_max_body_size. If you post directly to splitforms.com/api/submit, plan around the current 10 MB per-file limit.
How do I let users take a photo from their phone instead of picking a file?
Use the capture attribute on the file input: <input type='file' accept='image/*' capture='environment' />. The 'environment' value opens the rear camera, 'user' opens the selfie camera. On iOS Safari and Android Chrome this triggers the native camera UI directly. On desktop it's ignored and acts like a normal file picker. No JavaScript needed.
Does CORS work differently for multipart uploads?
Yes — multipart/form-data with a file is no longer a 'simple request', so the browser sends a CORS preflight (OPTIONS) before the POST. Your form backend has to respond to OPTIONS with Access-Control-Allow-Origin and Access-Control-Allow-Headers including Content-Type. splitforms handles this automatically. If you're rolling your own endpoint and seeing 'blocked by CORS policy' on uploads but not on plain POSTs, the missing OPTIONS handler is almost always the cause.
What about antivirus scanning — do I need to set that up myself?
If you're self-hosting, run a scanner such as ClamAV before storage and never forward raw uploads straight into an inbox. For splitforms, Storage-backed uploads are stored privately, executable/script-like extensions are blocked, and content type is checked server-side. Treat public upload forms as higher-risk surfaces and keep your accepted types narrow.