A form is the piece where the difference shows most between a site built on top of the platform and one built against it. The browser already knows how to submit data, show validation errors, manage focus state, and navigate to the result. It has known this for decades. And yet almost everyone's default reflex, mine included for years, is to intercept the submit, preventDefault(), wire up a React state, and reimplement all of that worse. This article is about the opposite pattern: writing the form so it works without a single line of JavaScript, and then using JavaScript to make it better —not to make it possible.
The base case: a <form> that already works
A form with action and method sends data to the server and navigates to the result. No JS. Inputs with required, type="email", minlength or pattern validate on the client without a line of code. The associated <label> gives you accessibility for free. The submit button shows a native browser loading state.
<form action="/api/contact" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Send</button>
</form>
That works on a phone with half a network, while your bundle is still downloading, with JS disabled, or when a third-party script blows up and takes hydration down with it. On an Astro site, where most islands don't even hydrate, this isn't a hypothetical: it's the page's normal behavior.
Validation can't live in one place only
This is where discipline usually breaks. People validate on the client with a library, and on the server they validate again with different hand-written logic. Two sources of truth, which diverge the moment someone changes a rule.
What I do is have a single schema —Zod— and use it on both sides. On the server it's the truth; the client is a courtesy to give fast feedback. And crucially: if the JavaScript didn't load, the server still validates and returns a page with the errors. The base case is never left unprotected.
// shared
export const contact = z.object({
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Tell me a bit more'),
});
// server: the only truth
export async function POST({ request }: { request: Request }) {
const data = Object.fromEntries(await request.formData());
const res = contact.safeParse(data);
if (!res.success) {
// no JS: render the page back with the errors
return renderWithErrors(data, res.error.flatten().fieldErrors);
}
await save(res.data);
return new Response(null, { status: 303, headers: { Location: '/thanks' } });
}
Note the 303 with Location. That redirect after a successful POST is the POST/Redirect/GET pattern, and it prevents refreshing the page from resubmitting the form. It's from 1998 and it's still correct.
💡 If your form breaks when JavaScript fails, you didn't have a form. You had a widget that looked like one.
Adding JavaScript on top, not underneath
With a solid base case, JS comes in to improve things. And it does so by listening to the submit of the form that already exists, not by building a new one.
form.addEventListener('submit', async (e) => {
e.preventDefault(); // only runs if this script loaded
const data = new FormData(form);
const res = contact.safeParse(Object.fromEntries(data));
if (!res.success) return paintErrors(res.error.flatten().fieldErrors);
button.disabled = true;
const r = await fetch(form.action, { method: 'POST', body: data });
button.disabled = false;
if (r.redirected) location.assign(r.url);
});
The key is the mental ordering: preventDefault() is an optional optimization that only happens if the script ran. If it doesn't run, the browser does its usual job. There's no broken path, there's a less polished path.
And notice something else: I use new FormData(form) instead of reading React state. The DOM is already the form's state. Keeping a copy in useState synced on every onChange is work the platform does for free, and it's the cause of half the form bugs I've debugged: controlled inputs that lose the cursor, browser autofill that doesn't fire the event, password managers that fill fields and React state never finds out.
What I gain and what I pay
I gain real robustness: the form works before hydration, on bad networks, with JS down. I gain accessibility nearly for free, because native elements already come with their semantics. I gain less code: no form state reducer, no form library, no duplicated validation.
What I pay is that certain very rich interactions cost more. A field with remote autocomplete, drag-and-drop file uploads, or a multi-step wizard with complex state don't come out of a bare <form>. There I do build a controlled component, but in a localized way: the complex island is controlled, and the rest of the form is still the platform. I chose this because complexity stays contained where I need it, in exchange for having two field-handling styles coexisting in the same file, which is ugly if you don't document it.
I also pay that the "no JS" case has to be tested. If you don't test it, it rots. On projects where this matters to me, I have a test that submits the form with a plain HTTP request, no browser, and checks that the response comes back with the errors rendered. It's a cheap test and it's the only one that guarantees the base case is still alive.
The conclusion isn't "don't use JavaScript". It's that JavaScript should be the second layer, not the foundation. A form that only exists once the bundle loads is a form with a dependency you never agreed to take on.