htmx + Django instead of a SPA: when it's the right call



We built the content-calendar screen in codeceibe's internal panel two ways: first sketched as an Angular module hitting a JSON API, then actually shipped as Django views returning HTML fragments that htmx swaps into the page. The second version took about a third of the code and shipped in two days instead of a week and a half. That's the concrete case for htmx on an internal, CRUD-heavy tool -- but it's not a universal answer, and it's worth being precise about why.

What actually changes

SPA data flow versus htmx data flowSPA (Angular)Browser (Angular)JSON API viewJS renders JSON to DOMhtmx + DjangoBrowser (htmx)Django viewHTML partial swapped in

The Angular version needs a JSON contract, a TypeScript model that mirrors the Django model, a service layer, and client-side state for "is this row saved." The htmx version needs a Django view that already exists for the full page, plus a small template fragment it can also return. There's no second source of truth for what a calendar entry looks like -- the Django template is the rendering logic, in both the full-page and partial case.

A real example

Rescheduling a post from the calendar is a button with three htmx attributes:

<button hx-post="/panel/calendar/{{ entry.id }}/reschedule/"
        hx-vals='{"date": "2026-11-02"}'
        hx-target="#entry-{{ entry.id }}"
        hx-swap="outerHTML">
  Move to Nov 2
</button>

The view does the normal Django thing -- validates, saves, and returns a re-rendered _calendar_entry.html fragment -- and django-htmx gives the view a request.htmx flag so the same view can return either the full page or just the fragment depending on whether the request came from htmx:

def reschedule(request, pk):
    entry = get_object_or_404(ScheduledPost, pk=pk)
    entry.scheduled_for = parse_date(request.POST["date"])
    entry.save(update_fields=["scheduled_for"])
    template = "panel/_calendar_entry.html" if request.htmx else "panel/calendar.html"
    return render(request, template, {"entry": entry})

Where it's the right call

  • The tool is internal and the users are your own team -- you don't need a native-feeling, fully client-rendered experience to justify the build cost.
  • The screens are fundamentally CRUD: list, filter, edit, save, with the server already holding every bit of relevant state in the database.
  • You want one template language and one source of truth for markup, not a Django template for the full page plus a parallel Angular component tree for the same data.
  • Your team is small and mostly backend -- htmx attributes are a much smaller surface to learn than a full SPA's routing, state management, and build pipeline.

Where it isn't

  • Genuinely client-side interaction -- drag-and-drop reordering across many items, a canvas editor, anything with frame-by-frame redraws -- fights htmx's request/response model instead of working with it.
  • You need the same business logic to run offline or inside a mobile shell. codeceibe's reader-facing Ionic app can't reuse Django template fragments; that logic has to live in a real API either way.
  • Client-side validation needs to be instant and complex (cross-field, async uniqueness checks debounced per keystroke) -- htmx can do this with hx-trigger="keyup changed delay:500ms", but past a certain amount of logic you're reimplementing a mini frontend framework in template fragments and query params.
  • You already have a dedicated frontend team and a component library -- the SPA's cost is sunk, and htmx would just be a second pattern to maintain alongside it.

The actual trade-off

htmx didn't remove state management, it moved it server-side, which is where codeceibe's CRUD screens already kept it, in the database. What we gave up is optimistic UI and offline-friendly interaction; what we got back is one template, one validation path, and no JSON contract to keep in sync with a model that changes every sprint. For the content calendar, that trade was obviously worth it. For codeceibe's reader-facing app, it wouldn't be -- which is exactly why that one stays Ionic talking to a real API.

Leave a comment

Anonymous comments are published after review.