DEV Community

Cover image for Build It HTML-First: Why a Plain Web Form Beat a React Rewrite
Arthur
Arthur

Posted on • Originally published at pickles.news

Build It HTML-First: Why a Plain Web Form Beat a React Rewrite

A utility company had a form problem. To apply for service you used either an ancient ASP form or a slow manual process — and because the company was a regulated monopoly, letting customer satisfaction slip risked millions in fines. Two expensive attempts to fix it had already failed. The most recent, a React app built by an offshore team, lasted three days online before complaints forced it offline: a mess of loading spinners and global state, not accessible, and — the detail that says it all — it tried to cram form data and image uploads into localStorage, which caps out at about 5 MB.

A developer rebuilt it from scratch, HTML-first, using Astro. The form worked perfectly with no JavaScript at all; JavaScript only enhanced it. When it launched, the number of people completing the form doubled. (That's the author's reported result, and the most interesting part is why it happened — more on that at the end.)

The lesson isn't "frameworks bad." It's that a pile of old, boring web techniques quietly outperform a heavy single-page app for a job like this. Here's what "HTML-first" actually means and how you build a form this way.

What "HTML-first" actually means

HTML-first means the page works as plain HTML on its own, and JavaScript is a progressive enhancement layered on top — never a prerequisite. The page renders, the form submits, the user can finish their task even if the JavaScript never loads, fails, or is running on a browser that chokes on it.

For a public service that framing isn't an aesthetic preference, it's a requirement. The developer's design goals were simple: it has to work on every machine possible, work when the connection is poor, and never lose data once the user has typed it. There's a now-famous anecdote by Terence Eden that captures why this matters — he describes watching someone in a housing-benefits office reading GOV.UK pages on a PlayStation Portable, a browser so limited it can barely open three tabs, because those pages are plain, lightweight HTML that works on anything. The web is for everyone, including the person on a decade-old Android phone standing in a field with one bar of signal. Shipping that person 20 MB of JavaScript before they can see a form is, to put it plainly, a ridiculous thing to do.

So: HTML that works on its own, enhanced by JavaScript where JavaScript helps. Everything below is a concrete way to deliver that.

What Astro actually brings

You don't strictly need a framework to build HTML-first — server-rendered templates in any language do it — but Astro fits the philosophy unusually well, which is why the rebuild used it. Its defining choice is that it ships zero JavaScript to the browser by default: pages render to plain HTML, and you opt into client-side JavaScript only for the specific components that need it (its "islands" model). That's progressive enhancement built into the framework's defaults instead of bolted on afterward. You write components in a comfortable, modern way, and the output is a lightweight HTML page with interactivity added exactly where you asked for it — which is precisely the shape an HTML-first form wants.

The form wizard, the old-fashioned way

The core of the rebuild is a pattern that predates the SPA era and is having a quiet renaissance (Remix made it fashionable again): each step of the form is its own page, and submitting posts to the server, which validates and redirects to the next step.


<form method="POST" action="/apply/step-3">
  <label for="postcode">Postcode</label>
  <input id="postcode" name="postcode" required />
  <button type="submit">Next</button>
</form>
Enter fullscreen mode Exit fullscreen mode

On the server, the handler validates the submission and, if it's good, redirects the browser to the next step:

POST /apply/step-3
  → validate the fields
  → save them to this session
  → 303 redirect to /apply/step-4   (or re-render step 3 with errors)
Enter fullscreen mode Exit fullscreen mode

That's the POST-redirect-GET pattern, and it needs exactly zero JavaScript. The browser does the submitting and the navigating; the server does the validating and the deciding. It's not glamorous, but it works identically on a flagship phone and a museum-piece, and there's no "20 MB of JS before the first field renders" tax. As the developer put it: this is just a big form. It isn't showing real-time data. It doesn't need to be a client-side application.

Never lose the user's data: server-side sessions

The second pillar is where the old React app fell hardest. Instead of holding the half-finished form in the browser (and certainly instead of stuffing uploads into a 5 MB localStorage bucket), give every form session a unique ID and save each step to the backend the moment it's submitted — fields and uploads alike.

session a1b2c3:
  step-1: { name, dob }            ← saved on submit
  step-2: { postcode }             ← saved on submit
  step-3: { upload: meter-photo }  ← saved on submit
Enter fullscreen mode Exit fullscreen mode

Now the user's progress lives on the server, not in a fragile browser tab. Close the tab, lose signal, switch devices, come back tomorrow — the data is still there. The developer reported that this paid off literally: in one case someone finished a form a month after they started it. Data once entered is never lost, which was an explicit requirement, and it's almost impossible to honor if the half-built form only exists in the client.

Uploads: post them, don't hoard them

The old React app's most telling mistake was trying to keep image uploads in localStorage, a roughly 5 MB box never meant for files. The HTML-first answer is the one the platform hands you for free: a file input in a form that posts to the server.

<form method="POST" action="/apply/step-3" enctype="multipart/form-data">
  <label for="photo">Photo of your meter</label>
  <input id="photo" type="file" name="photo" accept="image/*" required />
  <button type="submit">Next</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The enctype="multipart/form-data" is the only unusual part, and it's what lets a form carry a file at all. The browser uploads the file on submit; the server stores it — on disk, or in object storage — against the user's session, alongside the rest of the step's data. No client-side buffering, no size ceiling you'll blow through, and the upload survives a closed tab like every other field. The platform has handled file uploads since the 1990s; you almost never need to reinvent it.

Validation: the browser already does most of it

Here's where teams burn person-months they didn't need to. Before reaching for a JavaScript validation library, remember that every browser ships a validation system for free, via plain HTML attributes:

<input type="email" required />
<input type="text" name="postcode" required
       pattern="[A-Za-z0-9 ]{5,8}" />
<input type="number" min="0" max="100" />
Enter fullscreen mode Exit fullscreen mode

required, type="email", pattern, min/max, minlength — the browser enforces all of these, shows messages, and blocks submission, with no code from you. It's not pretty by default, and the native error bubbles are clunky, but the logic is already there and battle-tested.

So instead of replacing it, enhance it. The developer wrapped the form in a tiny custom element — an HTML web component, under 1 KB — that picks up the browser's own validation and makes it pleasant: it suppresses the native popup bubbles and instead writes the error into an element associated with the field (via aria-errormessage, which assistive tech reads), clears the error as you type once the field becomes valid, and re-checks on blur and submit.

<validation-enhancer>
  <form method="POST" action="/apply/step-3">
    <label for="email">Email</label>
    <input id="email" type="email" name="email"
           aria-errormessage="email-error" required />
    <div id="email-error"></div>
    <button type="submit">Next</button>
  </form>
</validation-enhancer>
Enter fullscreen mode Exit fullscreen mode

The custom element wraps ordinary HTML and brings it to life — no shadow DOM, almost no HTML generated in JavaScript. (The developer later open-sourced their version on npm as validation-enhancer; you can also write your own in a couple of dozen lines, because the browser is doing the actual validation.) The point is the layering, not the specific library.

How the layers fall back

The reason this is robust is that every layer degrades into the one below it, and the user always lands on something that works:

Layer Validates with If it fails…
Best the web component (inline, accessible, live) falls back to ↓
Good the browser's built-in HTML validation falls back to ↓
Always the backend, on submit re-renders the step with errors

A user with a modern browser and working JavaScript gets the nicest experience. A user whose JavaScript failed still gets the browser's native validation. A user on something truly ancient still can't submit bad data, because the server checks it regardless and shows the errors on the re-rendered page. Nobody is bounced; nobody loses their work. That's progressive enhancement done properly — not "works only if everything loads," but "works always, better when it can."

Why the numbers actually moved

So why did completions double? The developer's own explanation is the genuinely useful insight, and it's slightly unsettling: your JavaScript-based analytics can't see the users your JavaScript is bouncing. The old React app was silently failing for people on old browsers, flaky connections, and assistive tech — and because the analytics script is itself JavaScript, those failures never showed up in any dashboard. The users were always there. They were just invisible, and being turned away.

When the HTML-first version shipped, those people could suddenly complete the form, and they flooded in — "the analytics people didn't even know where these users were coming from." It's worth sitting with that: a heavy SPA doesn't just lose some users at the margins, it loses them without telling you, because the instrument you'd use to notice is made of the same thing that's failing. "Our numbers are fine" can mean "we can't see who we're losing."

When a single-page app is still the right call

This isn't an argument that SPAs are bad — it's an argument for matching the tool to the job. Client-side rendering genuinely earns its weight when the page is a real application: a live dashboard updating in real time, a collaborative editor, a map you pan and zoom, anything with rich, stateful interaction that would be miserable as full-page reloads. There, the JavaScript is doing essential work.

The mistake is reaching for that machinery by default for things that aren't applications — content pages, and forms. A multi-step form is, as the developer bluntly noted, just a big form. It collects some fields and submits them. Wrapping that in a client-side framework adds weight, fragility, and an accessibility burden in exchange for very little, and it quietly excludes exactly the users a public service can least afford to exclude. Ask which one you actually have before you pick the heavy option.

Build for the phone in the field

Picture the person on a decade-old Android with one bar of signal, standing in a field, trying to finish your form — and build so that person succeeds. Start with HTML that works on its own: one page per step, POST-redirect-GET, server-side sessions, the browser's own validation as the floor. Then add JavaScript only where it earns its place, as a layer that enhances and never gates. That order is what survives old browsers, bad connections, and whatever framework replaces this season's.

Top comments (0)