4 min read
HTML From the Server
templ, htmx and Tailwind instead of a JavaScript app
Part three of the boring stack. After the language and the router, the biggest decision: what runs in the browser.
The default for a web app today is two applications. A backend that serves JSON, and a frontend in React or Vue that turns that JSON into a user interface. Two codebases, two build pipelines, two sets of dependencies, and an API contract in between that both sides must keep in sync.
For the apps I build (forms, lists, dashboards, admin screens), that’s one application too many. My apps render HTML on the server and send it to the browser. Three tools make that pleasant.
templ: type-checked HTML
templ lets me write HTML components in Go files. They compile to Go code, so the compiler checks them. If a component expects a User and I pass something else, the build fails. If I rename a field, every template that uses it breaks at compile time, not in front of a user.
The generated Go files are committed to the repository. That means go build works on any machine without running a generator first. The templ version is pinned in go.mod and always runs as go tool templ, because a different version can generate code the pinned library doesn’t understand.
htmx: interactivity without an app
htmx adds a few HTML attributes that let any element make a request and swap part of the page with the HTML that comes back. A button with hx-post sends a form. A hx-target says where the response goes. The server returns a fragment of HTML, not JSON.
That covers most of what a single-page app does in a business tool: submit a form without a reload, update a list, show validation errors next to the field, open a dialog.
A few conventions keep it from getting messy:
- Errors have a fixed place. Every form has an error slot. When validation fails, the server tells htmx to put the error there, instead of into the list the form was supposed to update. Otherwise a failed save can look like a successful one and close the dialog with the input lost. A test checks that every form that can fail has that slot.
- When a change affects several places, reload. If saving one item changes counters, numbering or a sidebar, the server answers with
HX-Refresh: true. Patching five fragments is where htmx code gets fragile. - Toasts arrive out of band. Notifications are swapped into a fixed container next to the main response.
One rule matters more than it looks: a request that returns 200 in curl doesn’t prove an htmx interaction works. The attribute and its trigger must sit on the same element, or inside the form that carries them. I test interactions in a real browser.
Tailwind: styles next to the markup
Tailwind v4 is configured in CSS, with no JavaScript config file. Classes live next to the HTML they style, so a component carries its own look. For ready-made components (dialogs, dropdowns, tables) I use templui, which builds templ components with Tailwind.
This is the one place my stack needs Node: the Tailwind command-line tool. It runs inside the development container, so I never install Node on my machine.
What it costs
- Offline apps and very rich interfaces are hard. A collaborative editor or a drawing tool needs real JavaScript. My apps don’t.
- Every interaction is a round trip. On a slow connection that’s noticeable. In practice, a server-rendered fragment from a nearby server is fast enough for business software.
- Fewer people know this setup. Hiring for React is easier than for htmx. But there’s less to learn, and a Go developer understands the whole flow in an afternoon.
When I’d choose differently
For an app that lives in the browser, with offline mode, real-time collaboration or complex client-side state, I’d build a proper frontend. For everything else, the browser already knows how to display HTML. I let it.
Next: the database.