An ode to the boring HTML form

April 5, 2026 · 5 min read

Minimalist pattern — an ode to plain HTML forms

This is a short one, and it's an opinion piece, and the opinion is this:

The plain HTML form is the best piece of browser infrastructure we have. Stop wrapping it in things.

I mean the literal tag. <form action="..." method="post">, with some <input> elements inside, and a <button type="submit"> at the bottom. That's it. That's the whole technology.

It was specified in HTML 2.0 in 1995. It has been in every browser ever shipped since. It works without JavaScript. It works when JavaScript fails. It works on a ten-year-old Android phone on a train in a tunnel. It respects the back button. It handles file uploads. It has native validation. It understands accessibility attributes. It submits cross-origin when you tell it to. It is free, stable, documented, and reliable in ways that almost nothing else on the modern web is.

And somehow, in the year 2026, half the "how to build a contact form" tutorials I read start with npm install.

What I keep seeing

A tutorial opens by asking you to install a form library. Then a validation library. Then a toast notification library for the success state. Then a state management library because the form needs local state. Then a fetch wrapper because the built-in fetch is allegedly not good enough. Then a hook for the submit handler. Then a context provider for the form's error state.

At the end of this, after 400 lines of code and nine dependencies, you have a form that does exactly what a <form> tag did in 1995, except it no longer works without JavaScript, it's broken on mobile Safari in a specific way you haven't noticed, and the bundle it ships with is 180KB larger than it needed to be.

I'm not saying none of that code is ever justified. For complex multi-step flows with conditional rendering, sure, reach for a library. For a contact form — three fields and a button — you are writing a small ceremony to recreate what the browser already does.

The browser is on your side

Let me list the things the native <form> element gives you for free. Each of these is a feature that somebody, somewhere, has written a JavaScript library to replace, and the library is almost always worse.

Submission. You click a button of type submit, the browser sends a POST (or GET) to the action URL with the form fields serialized. No onSubmit handler required.

Field validation. required, type="email", type="url", minlength, maxlength, pattern — all enforced by the browser before the form submits, with error messages localized into the user's language, announced by screen readers, visually indicated in a platform-native way. You write zero lines of code.

Keyboard navigation. Tab moves between fields. Enter submits. Escape closes. You do not have to implement any of this. It exists. It has always existed.

Autocomplete. autocomplete="name", autocomplete="email", autocomplete="organization" — the browser fills these in from the user's saved profile, on desktop and mobile, without your JavaScript touching a thing. Users love this. A custom React form usually breaks it.

File uploads. <input type="file" multiple> with the form using enctype="multipart/form-data". You get a file picker, preview, multi-select, drag-and-drop on modern browsers, and a native upload progress indicator in the browser chrome. For free.

Accessibility. Labels associate with inputs via for and id. Required fields announce their required-ness. Error messages get read aloud. The form's name and type are exposed to assistive tech automatically.

Progressive enhancement. If the user's JavaScript is disabled, broken, or delayed (slow networks are a thing), the form still submits. The page navigates to the response. The user gets a thank-you page. Nothing failed.

History. The back button works. If the user submits and then hits back, they get the form with their data still in it, because the browser remembers. No "oops, your data is gone" state to manage.

Cross-origin submission. Setting action="https://example.com/form" just works. No CORS dance, no preflight, no JSON serialization, no mode: 'no-cors' incantation. The browser posts the form natively because form submission is not a CORS-protected operation.

That last one is why hosted form backends are possible at all. You can set your form's action to a completely different domain and the browser cheerfully POSTs to it. Try doing the same thing with fetch() and you're in for an afternoon of headers.

The specific mistake I keep making myself

Here is the mistake. I start building a contact form in Next.js or Astro or whatever, and I immediately reach for a React component for the form. Not because I need to — because React is on the page and it feels wrong to have a "plain HTML" island in the middle of it.

Then I write an onSubmit handler. Then I write state for the fields. Then I write an error toast. Then I write a loading spinner. Then I debug why the form is re-rendering on every keystroke. Then I realize I've spent an hour reimplementing what <form action="..."> would have given me in zero lines.

The fix is not to learn a better React form library. The fix is to stop using React for the form. The form is HTML. Let it be HTML.

In practice this looks like:

<form method="POST" action="https://api.formto.dev/f/your-form-slug">
  <label>
    Name
    <input name="name" required>
  </label>
  <label>
    Email
    <input name="email" type="email" required>
  </label>
  <label>
    Message
    <textarea name="message" required></textarea>
  </label>
  <button type="submit">Send</button>
</form>

Ten lines. No JavaScript. Works on every browser since 1999. Posts to a hosted backend that handles storage, notifications, spam, retries. This is not a proof-of-concept. This is the final version.

"But I need a custom UX"

Sometimes, fine. If you need inline error messages that update in real time, or a loading spinner after submit, or a conditional field that appears based on another field's value, you will write JavaScript. That's okay. The rule is not "never use JavaScript." The rule is "start with the boring version and add complexity only when you can name the reason."

Here's the test. For every line of JavaScript you add to a form, ask: what would happen if this line didn't run? If the answer is "the form would still work, it would just be slightly less nice," keep the line. If the answer is "the form would break," you've just introduced a failure mode that didn't exist when the form was plain HTML, and you need to decide whether the nicer UX is worth the new class of bugs.

Most of the time, it isn't. Most contact forms are fine without JavaScript.

The weird part

The weirdest thing about the plain HTML form is how much nostalgia it triggers in experienced developers when I show it to them. "Oh, right, you can just do that." Yes. You can. You could always do that. The form element did not disappear when React was invented. It just got covered up.

Uncover it. Delete your form library. Delete the onSubmit handler. Change the action URL to a hosted endpoint. Watch the form continue to work perfectly. Congratulate yourself on deleting 380 lines of code.

The manifesto, one more time

A contact form is not a place to show off. It is a place for a stranger to reach you. Every library you add between that stranger and their message is a tax on their patience, an invitation for a future bug, a gram of bundle weight, and a small betrayal of the thing HTML is good at.

The best contact form in the world is the most boring one. Plain tags. Real attributes. A hosted endpoint doing the ingestion. A success page saying "got it." Nothing else.

You are allowed to ship this. You are, in fact, strongly encouraged to.


If you want the "hosted endpoint" half of this architecture, FormTo is deliberately designed to be the thing your <form action="..."> points at. No SDK, no client library, no init step. Paste the URL, post the form, done.

Companion reading: the four-minute walkthrough for your framework of choice (same idea, different stack) and how forms die silently (why over-engineering the form is often how it ends up broken).

← All posts