Static site generators solved a genuine problem. Build the HTML once, serve it from a CDN, stop maintaining a server. The trade-off arrives the moment someone asks for a contact form, because a form needs something to POST to, and you have just spent considerable effort removing exactly that.
The standard answer is a hosted form endpoint: your form posts to a URL somebody else runs, and submissions come back to you as email, a dashboard entry, or a webhook. This is the complete path, including the parts that are usually left out.
What does the minimum working form look like?
Less than most tutorials suggest. A form element with an action and a method, some named inputs, and a submit button. No JavaScript, no framework integration, no client library.
<form action="https://simpleform.dev/f/YOUR_TOKEN" method="POST">
<label for="name">Name</label>
<input id="name" type="text" name="name" required>
<label for="email">Email</label>
<input id="email" type="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<input type="text" name="_honeypot" tabindex="-1" autocomplete="off"
aria-hidden="true" style="position:absolute;left:-9999px">
<button type="submit">Send</button>
</form>That is a working contact form. The browser serialises the fields, POSTs them, and follows the redirect the endpoint returns. It works with JavaScript disabled, it works in a text browser, and it works on the first page load with nothing to hydrate.
Two details in there are load-bearing. Every input has a matching label, which is the difference between a form real people can use and one that merely renders. And the hidden underscore-prefixed field is a honeypot, which costs nothing and removes most automated spam before it reaches you.
Where should the endpoint URL live in your project?
Not hardcoded in the component. Put it in configuration and reference it from the template.
In Astro, the endpoint belongs in an environment variable read at build time and passed into the component as a prop or read directly in the frontmatter. In Hugo, it belongs in site parameters and gets read through the site configuration in your partial.
The reason is not elegance. It is that form tokens change — you create a new form for a redesign, you split one contact form into two, you move an endpoint between accounts — and a value hardcoded into a component is a value that will still be in a component you forgot about six months later, silently collecting submissions into a form nobody reads any more.
How do you keep the visitor on the page?
Intercept the submit event and post with fetch. The important detail is the Accept header: send application/json and the endpoint returns a JSON body instead of a redirect, which is what lets you render your own success state.
const form = document.querySelector('#contact');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const status = document.querySelector('#form-status');
status.textContent = 'Sending…';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json' },
body: new FormData(form),
});
const data = await res.json();
if (data.success) {
form.reset();
status.textContent = 'Thanks — we will get back to you.';
} else {
status.textContent = data.message || 'Something went wrong.';
}
} catch (err) {
status.textContent = 'Network error. Please try again.';
}
});Three things this snippet gets right that most do not:
- It builds the body from
FormData. No manual field enumeration means no field silently missing when someone adds an input later. - It handles the network failure separately from the rejection. "We could not reach the server" and "the server said no" need different messages, because only one of them is worth retrying.
- It writes into a status element rather than an alert. Give that element
role="status"and the outcome is announced to screen readers instead of only being visible.
Keep the action attribute on the form even when you handle it with JavaScript. If the script fails to load, the form degrades to the plain HTML path instead of silently doing nothing when someone clicks Send.
What changes when you accept file uploads?
Two things in the markup, and rather more in your thinking.
In the markup: add enctype="multipart/form-data" to the form and a file input with a name. If you are posting via fetch with FormData, do not set a Content-Type header yourself — the browser must generate it, because it contains the multipart boundary.
<form action="https://simpleform.dev/f/YOUR_TOKEN" method="POST"
enctype="multipart/form-data">
<label for="resume">Attach your CV (PDF)</label>
<input id="resume" type="file" name="resume" accept=".pdf">
<button type="submit">Apply</button>
</form>In your thinking: an upload field turns a contact form into a system that receives arbitrary binary content from strangers and stores it. The accept attribute is a convenience for the file picker, not a control — it is trivially bypassed and must never be your only restriction. Whatever backend you use should be enforcing type and size limits server-side, and you should know what those limits are before you publish the form rather than after someone complains that their attachment vanished.
Tell the visitor the limits in the label. "Attach your CV" with a silent size cap produces a submission that fails at the worst possible moment, after the person has already filled in everything else.
What do people get wrong?
Treating the form token as a secret. It ships in your published HTML. Anyone can read it. It identifies a form; it does not authorise anything. Protect the endpoint with an allowed-origins list and rate limiting, and stop worrying about the token.
Skipping the success state. A form that redirects to a page saying nothing in particular, or that clears itself with no message, reads as broken. People submit again. You get duplicates and they get uncertainty.
Validating only in the browser. Client-side required and type="email" are user experience features. They are not validation, because nothing forces a submission to come from your form at all.
Never testing the failure path. Everyone tests that a valid submission works. Almost nobody tests what the visitor sees when the endpoint is unreachable, and that is the case where a silent failure costs you an actual customer.
What should you check before launch?
- Submit with JavaScript disabled and confirm the form still works.
- Submit from a phone, where the on-screen keyboard covers half the form.
- Tab through the whole form with the keyboard and confirm focus never lands in the honeypot.
- Confirm the notification email actually arrives, and that it arrives somewhere a person will read rather than a shared alias nobody opens.
- Confirm the allowed-origins list contains your production domain and not just your local dev server.
That last one is responsible for a startling share of "the form worked yesterday" reports. It worked yesterday on localhost.
Frequently asked questions
No. A plain HTML form with an action pointing at a hosted endpoint works with no JavaScript at all, and the browser handles the POST and the redirect. JavaScript is only needed if you want to stay on the page and show inline success or error states.
In your site configuration rather than inline in the template. Astro exposes environment variables to templates at build time and Hugo has site parameters. Either way you get one place to change the value and no risk of a stale token surviving in a component you forgot about.
No. It appears in your published HTML and anyone can read it. It identifies which form a submission belongs to; it does not authorise anything. Protect the endpoint with origin restrictions and rate limiting rather than by trying to hide the token.
Submit to the real endpoint from your local dev server. If you have origin restrictions enabled you will need to add your local origin to the allowed list temporarily, which is a good reminder to remove it before launch.