Guide
How can static sites accept form submissions without writing server code?
A static site can absolutely accept a form submission. It just cannot accept one by itself. This is the sentence that resolves most of the confusion around the question, because the confusion is rarely about forms — it is about what the word static actually rules out.
Static hosting means the server's entire job is to return files that already exist. There is no process reading the request body, no application deciding what to do with a name and an email address, and no SMTP connection from your hosting account to the visitor's inbox. When a form on such a site is submitted, the browser has to send that data somewhere else, and that somewhere else is the thing you are really choosing when you decide how to handle forms.
This guide answers the question in the order the decisions actually arrive. First, what static hosting rules out — and what it does not. Second, the four realistic ways to receive a POST, with the trade-offs that matter after launch rather than during setup. Third, the payload formats and the lifecycle that runs on the endpoint. Fourth, the two working implementations, plain HTML and JavaScript, including the cases that break in production. Fifth, spam, files, and the checks that separate an endpoint you can trust from one that merely returned 200 once.
What “static” actually rules out
A static host does three things well: it stores files, it serves them with reasonable cache headers, and it maps URLs to paths. It does a fourth thing by accident, which is to teach people a wrong mental model — because a page that renders and responds feels like an application, it is easy to assume something is listening on the other side.
Nothing is. On GitHub Pages, a POST to a page URL is refused. On a bare S3 bucket with static website hosting enabled, a POST is not part of the protocol the bucket understands. On a Cloudflare Pages project with no functions, an upload is answered by the asset store, not by code. The failure looks like a 405, a 404, or an HTML page returned with your data thrown away, and it is not a bug in your form: it is the correct behaviour of a file server.
What static hosting does not rule out is everything around the form. You can still validate on the client for a better experience. You can still redirect after submit so a refresh does not resubmit. You can still show errors next to the right field. You can still keep every field name semantic, keep autocomplete hints on inputs, and keep the labels associated with the inputs. All of that lives in the browser and survives in a static deployment.
The one thing you cannot do is hold the data. Storage, notification, autoresponse, retries, and the record of what happened next all require a process that outlives the request. That process is the form backend, and it can be yours or someone else's.
Why the browser cannot send the email itself
A reasonable next question is why the page cannot just talk to a mail server directly. The answer is a combination of protocol and policy, and both parts matter.
Browsers do not speak SMTP. There is no API that opens a socket to port 587, authenticates with credentials you would then have to embed in client-side JavaScript, and delivers a message. Every mail credential shipped to a browser is a credential published to the world; scrapers read bundles, and a compromised mail account is worse than a broken form.
There is a second, quieter reason. Even if a browser could send mail, a mail server would have to trust it. Deliverability depends on the reputation of the sending domain and on authentication records — SPF, DKIM, DMARC — that describe which servers are allowed to send as you. A browser is not one of those servers. Messages sent from a random client context to a receiving provider rarely reach an inbox, and they give you no delivery record when they do not.
So the browser's honest role is narrow: collect values, send them to one endpoint over HTTP, and render whatever answer comes back. Everything that has to be authenticated, retried, or recorded belongs on a server. That division is the whole architecture of a hosted form backend.
The four realistic ways to receive a POST
There are four options people actually ship. They differ less in how the markup looks — three of the four use identical HTML — and more in what you own after launch.
| Option | What answers the POST | What you own afterwards | Where it breaks |
|---|---|---|---|
A mailto: action |
The visitor's own mail client | Nothing — there is no record anywhere | No client configured, visitor closes the window, address scraped, no proof of sending |
| A hosted form endpoint | A third-party service built for form posts | An account, a destination inbox, and a dependency on someone else's uptime | Vendor change of terms; a plan limit you did not read; export quality |
| A serverless function you deploy | Your own code, on your own account | Secrets, retries, error paths, rate limiting, spam handling, storage | The failure path nobody monitors; a provider quota silently hit at 2am |
| A form feature built into the host | The hosting platform's own handler | Whatever that platform gives you, usually a dashboard and an email | Platform-specific markup; limited control; moving hosts means moving forms |
Two of these deserve a sentence each, because they are frequently mis-described.
A mailto action is not a backend. It delegates delivery to software you do not control, on a machine you cannot inspect, with results you never see. It is reasonable for a one-off internal page and unreasonable for a contact form that a business depends on.
Host-level form handling is real and can be a good default when it fits. Netlify Forms, for example, detects forms at build time and stores submissions for you, which is genuinely convenient. The cost is that the feature is part of one host's product surface: the markup learns host-specific attributes, and leaving the host means rebuilding the intake. If you are already committed to that platform for years, the trade may be worth it. If the site is portable, a form endpoint keeps it portable too.
What the browser actually sends
Understanding the request makes the debugging sessions short. A native form submission produces a document navigation, not a fetch, and it can encode the fields in two ways.
URL-encoded, the default
With no enctype, the browser serialises the fields as application/x-www-form-urlencoded — the same encoding as a query string, in the body. It is compact, universally supported, and easy to log. It cannot carry files.
Multipart, when there are files
enctype="multipart/form-data" switches to a boundary-delimited body that can carry binary parts alongside text fields. Almost every form that breaks mysteriously on submit is a file form that forgot this attribute.
JSON, when you write JavaScript
A fetch-based form can send application/json, which is pleasant for an API and useless for a native form. The moment you choose JSON you also choose to handle the response yourself, and you inherit the browser's cross-origin rules, which the native POST neatly sidesteps.
<!-- 1. Native, urlencoded, no JavaScript -->
<form action="https://example.com/f/FORM_ID" method="POST"> ... </form>
<!-- 2. Native, multipart, accepts files -->
<form action="https://example.com/f/FORM_ID" method="POST"
enctype="multipart/form-data"> ... </form>
<!-- 3. JavaScript, JSON, needs CORS on the endpoint -->
<script>
fetch('https://example.com/f/FORM_ID', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Ada', email: '[email protected]', message: 'Hello' })
});
</script>
Two details are worth internalising because they explain most "it works locally, not on the domain" reports. First, a native form POST is not subject to the same-origin policy: the browser navigates to the endpoint and shows whatever comes back. Second, a fetch POST is: the endpoint must return Access-Control-Allow-Origin for your origin, and it must answer the preflight if you send a non-simple content type such as JSON. That is why switching an existing form from a native POST to a fetch call sometimes converts a working form into a CORS error without touching the endpoint's code.
The lifecycle you do not control
A 200 response means one thing: the endpoint accepted the request. Everything a business actually cares about happens after that, on the endpoint, in an order that is worth knowing because each stage is a place where a submission can disappear.
Validation. Required fields, field types, length limits, and any rules the owner configured. Validation has to run on the server even when the client already did it, because the client is a suggestion. A form backend that trusts the browser is not a backend.
Classification. Spam is not an error — it is a record with a verdict attached. A good endpoint scores the submission, stores it either way, and marks the ones it believes are junk. Silently dropping a submission is worse than storing a false positive, because a false positive is inspectable and a drop is invisible.
Storage. The submission becomes a durable record with its field values, metadata (source, device, referrer, timestamps), and attachments. This is the part static hosting cannot do, and it is the part that makes the archive possible.
Delivery. Notification email to the owner, autoresponse to the visitor, webhook to another system. Each destination is a separate channel with its own status, its own provider message id, and its own failure mode. "The form is broken" almost always means one channel failed while the submission itself was stored correctly.
Observability. Retries with backoff, a delivery log you can read, a replay control for a webhook, and a suppression list for addresses that hard-bounced. Without this layer you learn about failures from a customer complaint, which is the most expensive way to learn.
When you evaluate a hosted endpoint, these five stages are the evaluation. Not the form builder, not the design of the dashboard — the record of what happened after the POST.
Implementation 1: plain HTML, no JavaScript
This is the version to ship first. It works in every browser, survives script blockers, cannot break when a bundler changes, and is trivial to test from a terminal.
<form action="https://freelyform.com/f/FORM_ID" method="POST">
<input type="hidden" name="_subject" value="Website contact">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email"
autocomplete="email" required>
<label for="topic">What is this about?</label>
<select id="topic" name="topic">
<option value="support">Support</option>
<option value="billing">Billing</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send message</button>
</form>
Three things in that markup carry disproportionate weight. Every field has a name, because unnamed fields are invisible to the server and produce the classic "the message arrives empty" bug. Each label is bound with for and id, which is both an accessibility requirement and the reason a screen reader announces the right field. And the autocomplete hints are the cheapest conversion improvement available — they let a browser fill the form in one tap on a phone.
After submit, choose one of three behaviours and be explicit about it. Return a success page from the endpoint (no work for you). Redirect to a thank-you page with a control field (clean history, one extra page to maintain). Or stay on the page with JavaScript (nicest, most fragile). The redirect approach is usually the best default because it follows the post-redirect-get pattern: a refresh on the thank-you page does not resubmit the form.
Implementation 2: JavaScript with inline feedback
Fetch is worth it when the form is inside a modal, a checkout step, or any place where a navigation would lose state. The cost is that you now own the error display, the pending state, and the CORS configuration.
<form id="contact">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
<p id="status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById('contact');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
status.textContent = 'Sending…';
try {
const response = await fetch('https://freelyform.com/f/FORM_ID', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form)))
});
if (response.ok) {
form.reset();
status.textContent = 'Thanks — your message was received.';
} else {
const problem = await response.json().catch(() => ({}));
status.textContent = problem.message || 'That did not go through. Please try again.';
}
} catch {
status.textContent = 'Network error. Please check your connection.';
} finally {
button.disabled = false;
}
});
</script>
Two rules make this pattern survivable. Disable the button while the request is in flight, or impatient visitors will send it three times and you will store three records. And render the endpoint's own error message when it provides one — a generic "something went wrong" hides the required-field problem that the visitor could fix in two seconds.
Spam, without a server of your own
Public forms attract bots within hours; the first junk message usually arrives before the search engine does. Because you have no server, the defence has to live in two places: cheap client-side hints that stop crude bots, and real server-side checks at the endpoint that stop the rest.
A honeypot field. Add an input that a person cannot see and cannot tab to, and check it at the endpoint. Bots fill every input they find. A filled honeypot is a confident spam verdict and a nearly free one.
A time trap. Stamp the page render time in a hidden field and refuse — or flag — submissions that arrive faster than a person could type. This catches scripted floods without any captcha at all.
Rate limits per form and per IP. Twenty submissions a minute from one address is not a customer; it is a script. Limits exist to bound damage, not to meter usage, which is why they should apply even on a generous plan.
A captcha when the form is genuinely public. Turnstile or an equivalent is the right tool for a high-traffic landing page and the wrong tool for a form behind a login. Put it where the abuse is, not everywhere by default.
Server-side content rules. A submission containing eleven links, a known junk phrase, or a disposable mail domain is classifiable without a human reading it. The value is not that the rule is clever; it is that a scored record is inspectable when it turns out to be wrong.
Whatever you use, prefer a system that stores the verdict rather than deleting the message. A false positive that you can see is a support ticket; a false positive that was dropped is a lost customer you will never hear from.
File uploads from a static site
Uploads work, and they bypass your static host entirely — the browser sends the file to the endpoint as a multipart part, and the endpoint decides what to do with it. That relocation is the important detail: the limits you can promise a customer are the endpoint's limits, not your repository's.
Before you advertise an upload, know the answers to five questions. How many files per submission, and how large may each be? Which types are allowed, and is the allowlist enforced on both the extension and the declared MIME type? Where are files stored, and are they reachable by guessing a URL? How long are they kept, and does deletion of the submission delete the files? And can a customer export them, or are attachments trapped in a dashboard?
A host that answers those five questions in writing is worth more than one that advertises "unlimited uploads" and answers none of them.
What to verify before you commit
The setup takes twenty minutes. The decision should be made on what happens in month seven, when something fails at an inconvenient time.
- Do you get a per-channel delivery record? One status line per destination, with the provider's message id and the error text when there is one.
- Are retries automatic, and are they visible? Retries you cannot see are indistinguishable from drops.
- Does the archive expire? Some plans keep submissions for thirty days. If your form is a legal or support record, that is a data-loss schedule rather than a feature. Ours does not expire on any tier — see the plan ladder.
- Can you export everything as CSV, and can you delete it? The exit path is part of the product, not a favour.
- Can you move the form without touching the markup? If the markup is standard HTML, changing the endpoint is a one-line edit. If it is provider-specific, migration is a rebuild — the migration checklist lists what to retest.
- What does a failure look like to the visitor? Send a request with a required field missing and read the actual message. Then decide whether a visitor could act on it.
When you should write server code instead
A hosted endpoint is the right answer for intake: a contact form, a quote request, an application, a waitlist, a bug report. It is the wrong answer when the form is really a transactional operation.
Write your own handler when the submission has to be written into an internal database in the same transaction as other state. When authorisation is complex enough that a per-form allowlist is not a model of it. When the payload has to trigger an orchestration with ordering guarantees, compensating actions, or a queue of its own. When a regulation requires the data to stay in a specific jurisdiction you control at the infrastructure level rather than contractually. And when the volume is high enough that per-submission pricing becomes the wrong shape for the budget.
Everything else — which is most forms on most websites — is intake, and intake is a solved problem that a static site can use without a server.
A launch checklist
- Post a real submission from the production domain with JavaScript disabled.
- Post one with a required field empty and read the error a visitor would see.
- Submit a file of an allowed type, then one of a disallowed type.
- Send the form twice quickly and confirm the button disabled state, or that the endpoint de-duplicates.
- Confirm the notification email arrived, and that the delivery record says sent.
- Fill the honeypot with a browser extension or curl and confirm the record is stored as spam.
- Export the archive as CSV and open it in a spreadsheet.
- Delete one submission and confirm the attachment went with it.
None of these take long, and each one corresponds to a failure that is cheap to fix before launch and awkward after.
Where to go next
- Static sites and contact forms — the short version for the three hosts this question is usually about.
- Quickstart — the endpoint reference, the three payload formats, and the error codes you will actually see.
- The contact form pattern — field-by-field decisions, including what not to ask.
- Getting a submission into an inbox — the delivery side in more detail.
- Plan ladder — what a free tier actually includes before you run out of it.
Static site form questions
Can a purely static site accept a form submission at all?
Yes, but not by itself. A static host only serves files, so something outside the site has to answer the POST. That something is either a hosted endpoint, a serverless function you deploy, or a form handling feature built into the host. The static site supplies the form markup; the endpoint supplies the server.
Do I need JavaScript to collect form data on a static site?
No. A plain <code>method="POST"</code> form with an absolute <code>action</code> URL works with JavaScript disabled, in older browsers, and behind extensions that block scripts. JavaScript is only needed if you want to stay on the page and print an inline success message.
Why does a POST to a static host return 405 or 404?
Because static hosts answer GET and HEAD from a file store. There is no application listening for a POST, so the request is refused. GitHub Pages, a plain S3 bucket, and an unconfigured Cloudflare Pages project all behave this way. The form did nothing wrong; it sent its request to a host that only knows how to read files.
Is a mailto: action a real option?
It is a fallback, not a backend. A mailto link depends on the visitor having a configured mail client, exposes your address to scrapers, produces no delivery record, and often opens a compose window the visitor closes without sending. It is acceptable for a low-stakes internal page and a bad choice for a contact form you actually rely on.
How do I keep form spam away without a server?
Put the checks where the server is: a hidden field bots fill, a timestamp that reveals an instant submission, per-IP rate limits, and an optional captcha on public forms. Client-side validation still helps the visitor, but it is a convenience, not a boundary — anyone can bypass it with curl.
What happens to file uploads on a static site?
The upload goes straight to the endpoint, not through your static host. That means the file limits, allowed types, storage location, and retention window are properties of the endpoint, not of your repository. Check them before promising a file upload to a customer.