DEV Community

ひとし 田畑
ひとし 田畑

Posted on

No SPA: a multi-panel database UI in Django + htmx + a sprinkle of Alpine

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
    })
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
<div id="table-list"{% if oob %} hx-swap-oob="true"{% endif %}></div>
Enter fullscreen mode Exit fullscreen mode

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 }}"}'>
Enter fullscreen mode Exit fullscreen mode

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 psql commands 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)