← Back to Blog

The Boring Stack

You probably won’t see any TED Talks about this

You won’t find any viral YouTube videos explaining why Go + HTMX + Templ + Alpine.js will “10x your productivity” or “change the way you think about the web forever.” There’s no influencer pointing at a camera promising it’ll land you a job at a FAANG company. And honestly that might be the best thing about it. I’ve been writing internal applications for over a decade at this point in my software development career. Admin panels, dashboards, internal tooling, the kind of stuff that employees use every day that nobody outside the company will ever see or care about. It’s not usually glamorous work, but it matters and it gets done a lot faster when you’re not fighting your own stack to do it. I’ve been using Go on the backend with Templ for HTML templating, HTMX for interactivity, and a sprinkle of Alpine.js for a little “pizzaz”.

What does ‘internal’ really mean?

Internal apps aren’t massive public facing behemoths like eBay, Instagram, etc. They’re usually small “good enough” applications used by the same fifteen people every day who just want to get their work done so they can go home and focus on what actually matters. They need to be fast, reliable, and most importantly: actually maintained, which means the codebase has to stay readable six months (or years) from now. Context matters; a React SPA with a separate robust REST API is a totally reasonable choice for a public-facing product if you’re a masochist who enjoys React. An internal CRUD app used by Amber in accounting to manage vendor contracts doesn’t need all of that overhead. You will spend more time managing dependencies than building useful features.

Go is boring. And that’s exciting.

I say this with genuine affection: Go is a deeply boring language. The standard library is feature packed and well-documented. There are roughly four ways to do any given thing and the community generally agrees on which one to use. For internal tooling that needs to actually run in production without a babysitter, boring is a feature. I’m not spending a Friday afternoon debugging a dependency conflict or figuring out why my build suddenly broke because a transitive package three layers deep decided to push out a breaking change. Instead, I’m writing features. The cognitive overhead of Go is just… low. Embarrassingly, pleasantly low.

Templ is what HTML templating should be

Before Templ I was using Go’s built-in html/template package, which works fine but always felt a little like solving a puzzle. Templ is a component-based templating language for Go that compiles down to type-safe Go functions. You write normal, boring HTML with Go embedded in it, run the generator, and that’s it. Passing the wrong type to a template component is now a compilation error rather than a runtime surprise a user discovers at 2am. Refactoring a component means your tooling tells you everywhere it’s used and what needs to change. It sounds small but the difference in confidence day to day is significant.

HTMX isn’t perfect but it’s almost perfect

HTMX is usually the one that raises eyebrows. “Isn’t that the thing for people who don’t want to write JavaScript?” More or less, yeah, and that’s why it’s great. I say this as someone who loves JavaScript (and who is actively using Astro to build his website/blog) - there’s too much goddamn JavaScript where it isn’t needed. Instead of generating new HTML, HTMX lets your existing HTML elements make HTTP requests and swap the responses into the DOM using attributes baked right into the element. A button can POST to something like /approve-vendor and replace itself with a success message when it comes back. A search input can GET /search?q=whatever on every keystroke and update a div with the returned HTML all without the need for JavaScript, external state management library, or JSON serialization/deserialization. For example:

JavaScript

let debounceTimer;
input.addEventListener("input", (e) => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(async () => {
    const res = await fetch(`/search?q=${e.target.value}`);
    const data = await res.json();
    renderResults(data);
  }, 300);
});

HTMX

<input
  type="text"
  name="q"
  hx-get="/search"
  hx-trigger="input changed delay:300ms"
  hx-target="#results"
/>
<div id="results"></div>

Inline Confirmation

JavaScript (requires a function)

async function deleteItem(id) {
  if (!confirm("Are you sure?")) return;
  await fetch(`/items/${id}`, { method: "DELETE" });
  document.getElementById(`item-${id}`).remove();
}

HTMX

<button
  hx-delete="/items/42"
  hx-confirm="Are you sure?"
  hx-target="closest tr"
  hx-swap="outerHTML"
>
  Delete
</button>

In the example above, it’s just easier to tell what the HTMX is doing at a glance. The server renders the HTML and ships it back to the browser then HTMX handles the swap. I’ve been able to build multi-step forms, live search, sortable tables, modal dialogs, and paginated lists without the need for any JavaScript and that feels nice. It also means your backend developer and your frontend developer can be the same person without that person needing to juggle three different mental models at once. For small teams building internal tooling this MATTERS.

Alpine fills in the gaps

I’m not going to pretend HTMX handles everything. Sometimes you do need that little bit of extra interaction JavaScript was made for. Alpine.js is about fourteen kilobytes and handles all of it with a handful of x- attributes on your HTML elements. Here’s why I like it vs using a full-blown framework like React:

Fancy Dropdown

React

import { useState } from "react";
function Dropdown() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Menu</button>
      {open && (
        <ul>
          <li>Option A</li>
          <li>Option B</li>
        </ul>
      )}
    </div>
  );
}

Alpine

<div x-data="{ open: false }">
  <button @click="open = !open">Menu</button>
  <ul x-show="open">
    <li>Option A</li>
    <li>Option B</li>
  </ul>
</div>

Conditional Forms

React

const [role, setRole] = useState("");
return (
  <>
    <select onChange={(e) => setRole(e.target.value)}>
      <option value="user">User</option>
      <option value="admin">Admin</option>
    </select>
    {role === "admin" && (
      <input type="text" placeholder="Admin access code" />
    )}
  </>
);

Alpine

<div x-data="{ role: 'user' }">
  <select x-model="role">
    <option value="user">User</option>
    <option value="admin">Admin</option>
  </select>
  <input
    x-show="role === 'admin'"
    type="text"
    placeholder="Admin access code"
  />
</div>

It’s declarative and lives next to your markup. x-data, x-show, x-bind, x-on,etc - you can read the attribute and understand what it does without needing to reference the docs. The mental overhead is minimal and it plays nicely alongside HTMX without any friction. It’s the right amount of JavaScript for the right amount of problem.

TL;DR

What this stack gives you that’s harder to articulate is a kind of legibility. When I come back to a project after two months I can open a handler, see what template it renders, open the template, see exactly what HTML it produces and where the HTMX attributes point, and understand the whole flow in about three minutes. There are no hidden abstractions between the request and the response. There’s no client-side routing to trace through, no component lifecycle to reason about, no question of where the state actually lives. Internal apps live or die on maintainability. They often get touched infrequently, by whoever has time, months or years after they were written. A stack that stays readable under those conditions is not just a “nice-to-have”, it’s a game changer and will allow future devs to quickly address bugs or add features later without feeling the “let’s re-write it all” itch. I’m not saying this is the best stack for every web application. I’ll go out on a limb and say up-front it isn’t. But for the internal tooling that keeps businesses running day to day and the stuff that needs to work reliably, ship quickly, and not fall apart the moment the original author changes jobs this stack is amazing. It stays out of your way and lets you get shit done.

  • Travis