Short version for the impatient: I rewrote our internal admin in htmx six months ago and it’s been the best decision I made all year. I also kept our customer dashboard in React and have zero plans to change that. If you want to know why, read on.
Both can be the right answer. Just not at the same time.
The trip that started this
In November I had a 1,200-line Next.js admin app that was three forms and a table. The forms saved data. The table showed data. There was a filter dropdown. That was the whole feature surface.
The bundle was 84 KB gzipped. We had three useEffect bugs that month, two of them in the filter dropdown. I’d just reread Carson Gross’s “Locality of Behaviour” essay and decided to try htmx for one tiny side feature. Two weekends later I’d ported the whole panel. The new admin is 11 KB of HTML, no build step, no useEffect, no zustand, no react-query. It runs.
Then I tried to do the same thing with our customer-facing dashboard. I lasted about six hours before I came crawling back to React. So here we are.
What htmx actually is (and isn’t)
htmx is roughly 14 KB of JavaScript that adds attributes like hx-get, hx-post, and hx-target to plain HTML. Your server returns HTML fragments instead of JSON. The library swaps those fragments into the page.
That’s the whole library. There’s no virtual DOM, no component tree, no hydration. You’re rendering on the server, your app’s “state” lives in the database, and the browser is mostly responsible for showing what the server sends.
A typical interaction looks like this:
<button hx-post="/api/items"
hx-target="#items-list"
hx-swap="afterbegin">
Add item
</button>
<ul id="items-list">
<!-- server returns a new <li> and htmx prepends it here -->
</ul>
Compare that to the React equivalent:
function AddItem() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () =>
fetch('/api/items', { method: 'POST' }).then(r => r.json()),
onSuccess: (item) => {
queryClient.setQueryData(['items'], old => [item, ...old])
},
})
return (
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add item'}
</button>
)
}
The React version is more flexible. It also has roughly four extra abstractions to learn before it works. For a simple admin form, that’s a tax I keep paying with no benefit.
That said, htmx is not a SPA framework with extra steps. It’s a different shape of app. If you want offline support, optimistic UI that survives network failures, or interactive client state that doesn’t round-trip, you’re going to fight htmx and lose.
Where htmx beat React for me
Three places, specifically.
First, internal CRUD. Admin panels, team dashboards, anything where the user is technical and on a connection we control. The “render full HTML on the server” model is ideal here. Round-trips are cheap, and I get to use whatever templating language my backend already speaks. I’m running PHP for the admin, so I get property hooks and asymmetric visibility in my view models for free. No serialization layer between the model and the screen.
Second, forms with server validation. Every form has the same lifecycle: type, submit, validate, return errors or success. In React I usually write validation rules twice, once on the client with Zod, once on the server in whatever language the backend speaks. With htmx I write it once on the server and return the form HTML with the errors already in place. hx-post="/items" on the form, hx-target="this" and hx-swap="outerHTML", and the form replaces itself with whatever the server sent. That’s the whole feature.
Third, pages that mostly aren’t interactive. Most pages on most apps are text and a couple of buttons. React’s fixed cost (download, parse, hydrate) is the same whether the page does 100 things or 1. htmx’s fixed cost scales with what’s actually on the page.
The killer thing for me is the boring debugging story. When something breaks, the answer is in the network tab. A request and a response, both HTML. I can read both. No virtual DOM diff, no React DevTools, no question of which render this is.
Where I went back to React
I tried to port the customer dashboard and bailed. Here are the reasons, in order of how much they hurt.
Real-time updates first. The dashboard streams pricing updates over a WebSocket. I had this working in React with a thin reducer. In htmx I ended up with a server-sent-events extension, a hidden div, and a workflow that swapped pricing rows individually as updates came in. It worked for a single tab. With three tabs open and updates rolling in, the layout would flicker. I’m sure someone smarter than me has solved this. I just didn’t want to spend a week on it.
The filter UI was second. Multi-select with chips, a date range, three numeric ranges, all combinable. Every filter change should update the visible result without a page reload. In htmx that’s seven hidden inputs and a hairy hx-include attribute. In React it’s a useReducer with a filters object. The React version is shorter and easier to test.
Drag-and-drop reordering came third. htmx has a Sortable extension and it works for simple lists. We had nested groups with constraints, where you could drag a card into one group but not another. I built a small proof of concept and decided I’d rather use dnd-kit and call it a day.
Animations that depend on outgoing state were last. hx-swap has a transition:true option that uses the View Transitions API, and it handled most of what I needed. For a drawer that slides out while a new view slides in with the old DOM still measurable, I gave up.
The pattern: anywhere the user expects rich interactive feedback that doesn’t map cleanly to “submit something, get HTML back,” React earns its weight.
How I actually decide now
I ask myself three questions.
Is the user’s network reliable? If they’re in our office or on a paid product where we control the experience, htmx is fine. If they’re mobile users on patchy connections who expect “it always feels fast,” React with proper loading states wins.
How much state lives only in the browser? A dashboard with filters, a sort order, and a currently selected row can be modeled as URL params and server state. That’s htmx territory. A real-time multi-user collaboration tool with cursors and presence is React territory. Or, honestly, a dedicated tool like Liveblocks.
Who’s writing this? htmx assumes you can write good HTML and that your server is fast. If your team is mostly frontend specialists who don’t want to think about templates, React is a kinder default. I’m a full-stack person and I like the htmx model. Not everyone does.
The thing I want to push back on: this isn’t a new vs. old debate. It isn’t htmx-the-startup-killer against React-the-incumbent. They’re solving overlapping but different problems, and the right move is usually to pick per-feature, not per-app. I’ve shipped a Next.js app that calls server actions on a few routes and uses full client components on others. It’s fine. Users don’t care.
What I’d try this week if I were starting over
If you’ve never touched htmx, build one thing with it. Pick the most boring CRUD form in your app, the one with two inputs and a save button. Try replacing it with htmx in an afternoon. Two outcomes are possible.
It’s faster than your React version, in development time and at runtime, and you start asking what else you can simplify. That’s what happened to me.
Or it feels constraining and you go back to React with a clearer sense of what React was actually doing for you. That’s also a useful outcome.
If you want a starting point, the htmx examples gallery is the best learning resource I’ve seen for any web library in years. Each example is a single HTML file you can read in sixty seconds. It’s the opposite of the React docs, in a good way.
I write up more of these tool-vs-tool comparisons over on my work page if that kind of thing helps.