Go + HTMX + Templ: Shipping Data Apps Without a JavaScript Framework

by Ridhwaan Mayet, Data Engineering Consultant

The tool is small. Why is the stack huge?

Most internal data apps are the same shape. A table of records. Some filters. A form or two. Maybe a chart and a number that should update while you watch it.

The default way to build that today is a single-page application: React or Vue on the front, a JSON API behind it, a bundler in the middle. For a product with thousands of users and rich client-side behaviour, that stack earns its keep. For a fleet dashboard used by six operations people, it mostly generates work:

  • Build tooling. A bundler, a transpiler, a dependency tree that needs patching on a schedule whether or not the app changed.
  • Hydration and state. The server renders nothing useful; the client fetches JSON, holds state, and re-derives HTML the server could have sent in the first place.
  • An API layer you didn’t need. You write endpoints, then TypeScript types for them, then client code to call them: a serialization boundary invented purely so the browser can rebuild what the database already knew.
  • Two deployments. A static bundle and an API service, each with its own pipeline and failure modes.

I write production Go daily: the ingestion fleet and partner-API integrations behind the Decarb.Earth case study are Go services pulling telemetry from fifteen vendor ecosystems into a warehouse. When the question is “we need a small web tool over this data,” my recommendation increasingly isn’t a second stack bolted onto the first. It’s the same language, rendering HTML directly: Go, HTMX, and Templ.

To be clear about grounding: my production Go experience is in data pipelines and integrations, not a shipped Go+HTMX product. What follows is the architecture I recommend and build with for internal data tools, and why I think it’s the right default for that class of app.

The hypermedia approach

HTMX is a small JavaScript library (~14 kB gzipped as of writing) that extends HTML with attributes. Instead of fetching JSON and rendering it client-side, an element makes an HTTP request and swaps the returned HTML fragment into the page.

<button hx-get="/plants?page=2"
        hx-target="#plant-table"
        hx-swap="outerHTML">
  Next page
</button>

Click the button, the server renders the next page of the table, and HTMX replaces the current one. That’s the whole model. The server owns state and rendering; the browser goes back to being a hypermedia client, which is what it was built to be.

This deletes entire categories of work. There is no client-side state store, because state lives where it always lived, in the database and the URL. There is no JSON API to version, because the contract is “the server returns HTML.” There is no hydration mismatch, because nothing hydrates.

Templ: templates that fail at compile time

Go ships with html/template, and it’s fine. Until you rename a field. html/template resolves names at runtime, so a typo in a template is a 500 in production, not a red squiggle in your editor.

Templ fixes this. Templ components are Go functions, compiled to Go code by templ generate. Parameters are typed. Calling a component with the wrong argument, or referencing a field that doesn’t exist, is a compile error. As of writing, you also get editor autocomplete and go-to-definition through its LSP.

templ PlantRow(p Plant) {
  <tr>
    <td>{ p.Name }</td>
    <td>{ p.Region }</td>
    <td>{ fmt.Sprintf("%.1f kWh", p.YieldToday) }</td>
  </tr>
}

templ PlantTable(plants []Plant, page int) {
  <table id="plant-table">
    for _, p := range plants {
      @PlantRow(p)
    }
  </table>
}

Components compose like functions because they are functions. Output is contextually escaped. And because everything compiles into the binary, the templates that ship are the templates you tested. Coming from years of maintaining typed Go pipelines, this matters to me: the same compiler that guards my ingestion code guards my UI.

What a data app looks like in this stack

Concretely, the shape of a small analytics or operations tool:

Tables with pagination and filtering. The server renders the table. Filter inputs and pagination links carry hx-get attributes pointing at the same handler with different query parameters; the handler re-renders just the table fragment. Deep-linkable, back-button-friendly, zero client state.

<input type="search" name="q"
       hx-get="/plants" hx-target="#plant-table"
       hx-trigger="input changed delay:300ms" />

Forms. A <form> with hx-post. The handler validates, writes, and returns either the updated fragment or the form re-rendered with inline errors. Validation logic lives in one place, in Go, next to the data.

Live updates. For a dashboard that should tick over, hx-trigger="every 30s" polling is often enough and trivially cacheable. When updates need to push (a long-running job’s progress, a fleet health counter), HTMX’s SSE extension swaps fragments as the server streams them. Server-Sent Events over plain HTTP; no WebSocket infrastructure unless you genuinely need bidirectional traffic.

Charts. This is where people assume they need React. Usually they don’t. A sparkline or bar chart is a few dozen SVG elements: render it server-side in a Templ component from the same query that built the table. For heavier charts, render a PNG server-side, or concede a small, contained JS island: one <canvas>, one chart library, initialised by a few lines of script. An island is not a framework. The page still works without a build step.

Top tip

Design every handler to render the full page on a normal request and just the fragment on an HTMX one (check the HX-Request header). You get graceful degradation, working deep links, and testability with nothing but curl.

Deployment: one binary, one cheap box

This is the part that lands hardest with the teams I work with. go build produces a single static binary: handlers, templates, migrations, assets all embedded via embed.FS. Deployment is: copy the binary, restart the service. No node_modules, no bundler in CI, no runtime to patch on the server.

That binary runs comfortably on the smallest VM your cloud provider sells. For South African teams paying for hosting in rands (where a managed frontend platform plus a container service plus a bandwidth bill all price in dollars), a data tool that idles at a few tens of megabytes of RAM on one small instance is a real line-item difference. It’s also a resilience difference: fewer moving parts means fewer things to page you.

Top tip

Put the binary behind Caddy or nginx for TLS and you’re done. A systemd unit and a deploy script that copies one file is a complete CI/CD story for an internal tool.

Honest limits

This stack is not the answer to everything, and pretending otherwise is how architecture advice loses credibility.

  • Rich client interactivity. Drag-and-drop dashboard builders, collaborative editing, canvas-heavy map work, offline-first apps: anything where the client legitimately owns complex state wants a real JavaScript framework. The Decarb impact map I describe in the case study is deck.gl inside Next.js, and that’s the right tool there: heavy client-side WebGL rendering is exactly the workload SPAs exist for.
  • Team familiarity. If your team is five strong React engineers and zero Go engineers, the “simpler” stack is the one they already know. Hypermedia thinking is a genuine unlearning exercise for developers raised on JSON APIs.
  • Ecosystem gravity. Component libraries, design systems, and hiring pipelines all skew SPA. With Templ you’ll build more of your UI yourself, albeit with Tailwind and standard HTML doing most of the lifting.
  • Churn hedge. HTMX and Templ are both actively maintained and stable as of writing, but they’re smaller ecosystems than React. Weigh that against how little of them you actually depend on: the failure mode is “keep running the pinned version,” not “rewrite.”

Choose this stack when / avoid it when

Choose Go + HTMX + Templ when…Avoid it when…
The app is an internal tool, admin panel, or data dashboardThe client owns rich, complex state (editors, drag-drop, offline)
Your backend or data pipelines are already GoThe team is deep in React/Vue and has no Go
Server round-trips are acceptable UX (they usually are)You need heavy client-side rendering, WebGL maps, canvas viz
Hosting cost and operational simplicity matterYou’re building a public product where SPA ecosystem tooling pays off
A small team must maintain it for yearsDesigners hand you component-library-shaped specs daily

The pattern I keep returning to: match the stack’s complexity to the app’s complexity. Most data tools are simple apps wearing complicated clothes.

If you’re weighing a build like this (an internal dashboard, an operations tool, a lightweight product over an existing data platform), I can help you scope it honestly, including whether this stack fits your team. Start with the scoping tool, or just get in touch.

More articles

API Ingestion at Scale: Lessons from Unifying 15 Inverter Vendor APIs

Practical lessons from building a Go ETL pipeline that ingests solar telemetry from 15 inverter vendor APIs across 24,350 plants: rate limits, data quality, idempotency.

Read more

Do You Need a Data Lake? DuckDB and MotherDuck as the Pragmatic Alternative

Most businesses told they need a data lake or lakehouse don't. When Parquet + DuckDB or MotherDuck covers your analytics, and when Postgres alone is enough.

Read more

Tell me about your business challenge

My offices

  • Johannesburg
    South Africa
    2198