I built a database console with a table browser, a SQL runner, an index lab, an EXPLAIN-plan differ, live activity and locks panels, snapshots, and a streaming backup/restore. A dozen panels, lots of moving parts.
There is no build step. No node_modules. No bundler, no framework CLI, no npm run anything. The <head> is three <script> tags from a CDN:
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
That's the whole frontend toolchain. And the surprising part isn't that it works — it's that it made the app deeper, because adding a panel got boring.
The one pattern
Every section of the UI is the same three pieces: one nav button, one view, one template. Click the button, htmx fetches a partial, swaps it into the main pane. That's it.
The nav button just declares where to fetch and where to put it:
<button hx-get="{% url 'overview' connection.pk %}"
hx-target="#detail" hx-swap="innerHTML">⌂ overview</button>
<button hx-get="{% url 'query' connection.pk %}"
hx-target="#detail" hx-swap="innerHTML">SQL</button>
<button hx-get="{% url 'history' connection.pk %}"
hx-target="#detail" hx-swap="innerHTML">History</button>
The view does the database work and renders a plain Django template fragment — no JSON, no serializer, no client-side rendering:
def table_detail(request, pk):
"""Columns + a row preview for one table (htmx partial into #detail)."""
connection = get_object_or_404(Connection, pk=pk)
engine = get_engine(connection)
return render(request, "partials/detail.html", {
"columns": engine.list_columns(schema, table),
"indexes": engine.list_indexes(schema, table),
"preview_rows": engine.preview_rows(schema, table).rows,
})
The template is the same HTML you'd write for a full-page render, minus the <html> wrapper. The server already knows how to turn data into markup — that's what templates are. htmx just lets you ship a fragment of that markup to a fragment of the page.
So "add a panel" is purely mechanical: add a path(), write a view, write a template, drop in a button. There's no new state to wire into a client store, no API contract to keep in sync, no round-trip of "shape the JSON / reshape it back into DOM." That low ceremony is why the panel count grew — coverage got wide because each addition was cheap.
Forms are just buttons that POST
Mutations are the same shape, with hx-post and a confirm where it bites:
<form hx-post="{% url 'table_truncate' connection.pk %}"
hx-target="#detail" hx-swap="innerHTML"
hx-confirm="Truncate this table? This permanently deletes every row.">
<button class="btn btn-danger">Truncate</button>
</form>
The view re-renders the same detail partial afterward, so the panel updates itself with the now-empty preview. No optimistic UI, no manual DOM patching — the server re-states the truth and htmx swaps it in.
When one action needs to touch two places — say a rename updates the main pane and the sidebar tree — you don't reach for client state. You append an out-of-band fragment to the same response:
# Same round trip: re-render the sidebar and let htmx place it by id.
response.content += render_to_string(
"partials/table_list.html",
{"tables": tables, "oob": True}, request=request,
).encode()
<div id="table-list"{% if oob %} hx-swap-oob="true"{% endif %}>…</div>
Two regions, one request, zero JavaScript.
Where Alpine earns its keep
If the server owns navigation and data, what's left for JS? Only the genuinely client-side bits — state the server has no opinion about:
- a slide-over drawer that toggles open/closed,
- a filter builder where you add/remove condition rows,
- copy-to-clipboard, and a spinner while a file download streams.
That's exactly Alpine's lane: a few lines of local component state, no global store for things the database already knows. The filter builder is fifteen lines of x-data, not a Redux slice.
And there's almost no boilerplate tax for the no-build choice. CSRF is wired once, on <body>, so no form needs a hidden token:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
The honest part: where this breaks
This is not "SPAs are bad." htmx-over-the-wire is the right tool when the server stays the source of truth and interactions are request/response shaped — click, fetch, swap. A database console is exactly that: every panel is a question you ask the database and a fresh answer it renders.
It would be the wrong tool the moment you need rich, offline-ish client state: a drag-and-drop canvas, a collaborative editor, an interactive chart you pan and zoom, anything where the UI holds significant truth the server isn't round-tripping. Push htmx there and you'll fight it — re-fetching for interactions that should never hit the network. Each section swap is also a real HTTP request, so a truly chatty, keystroke-latency UI wants a client framework.
But for a tool that's fundamentally "show me what the database says, then let me change it," giving up the build step cost nothing and bought a codebase where the next feature is four small, obvious files.
This is one piece of cli2ui — a local-only web UI over the
psqlcommands you keep half-remembering. No AI, no SaaS. It's MIT-licensed on GitHub. What command do you reach for that should be a button?
Top comments (0)