I am going to say something that my React-brain fought hard against for months: you probably do not need a JavaScript framework for your Django app.
Last October, we rebuilt a client's internal dashboard -- originally a React SPA talking to a Django REST Framework API -- as a server-rendered Django app with HTMX for interactivity. The React version shipped 847KB of JavaScript (gzipped: 231KB). The HTMX version ships 48KB total. The React version needed a Node build pipeline, a separate deployment, CORS configuration, token refresh logic, and a state management library. The HTMX version needs Django templates and a 14KB script tag.
The client's exact words after three weeks: "It feels the same but the pages load instantly."
My React-trained instincts screamed at me through the entire process. Every time I reached for useState, I had to stop and think "how would a Django template handle this?" And almost every time, the answer was simpler than I expected. Not always -- I will be honest about the rough edges. But the 80% case? HTMX makes Django feel like a full-stack framework again.
What HTMX Does (In 30 Seconds)
If you have not encountered HTMX yet, the core idea fits in one sentence: HTMX lets any HTML element make HTTP requests and swap the response into the DOM.
<!-- Click this button, POST to /tasks/create/, replace #task-list with the response -->
<button hx-post="/tasks/create/"
hx-target="#task-list"
hx-swap="afterbegin">
Add Task
</button>That is it. No JavaScript. No event listeners. No fetch() calls. No JSON parsing. No DOM manipulation. The server returns HTML, and HTMX puts it where you told it to.
The mental model shift is this: instead of your server returning JSON that your frontend turns into HTML, your server just returns the HTML directly. Your Django templates become your component library. Your views become your API.
This is not a new idea -- it is literally how the web worked before SPAs. HTMX just makes it feel modern by adding the partial page updates that make SPAs feel snappy.
The Stack: HTMX 2.0 + Alpine.js + Django 6
Here is our standard stack for HTMX-based Django projects in 2026:
- Django 6.0 -- server-side rendering, URL routing, ORM, auth, all the batteries
- HTMX 2.0 -- HTTP-driven interactivity (14KB gzipped)
- Alpine.js -- client-side state for the stuff HTMX cannot do (toggles, dropdowns, modals) -- 8KB gzipped
- django-htmx -- middleware that adds
request.htmxso your views know whether they are handling a full page request or an HTMX partial
The key architectural pattern is dual-purpose views:
# views.py
from django_htmx.http import HttpResponseClientRedirect
def task_list(request):
tasks = Task.objects.filter(user=request.user).select_related("project")
if request.htmx:
# HTMX request -- return just the task list partial
return render(request, "tasks/_task_list.html", {"tasks": tasks})
# Full page request -- return the complete page with layout
return render(request, "tasks/task_list.html", {"tasks": tasks})<!-- templates/tasks/task_list.html (full page) -->
{% extends "base.html" %}
{% block content %}
<h1>Your Tasks</h1>
<div id="task-list">
{% include "tasks/_task_list.html" %}
</div>
{% endblock %}<!-- templates/tasks/_task_list.html (partial) -->
{% for task in tasks %}
<div class="task-card" id="task-{{ task.id }}">
<h3>{{ task.title }}</h3>
<span class="badge">{{ task.status }}</span>
<button hx-delete="/tasks/{{ task.id }}/"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this task?">
Delete
</button>
</div>
{% empty %}
<p class="empty-state">No tasks yet. Create one above.</p>
{% endfor %}The _ prefix for partial templates is a convention we adopted -- it makes it immediately obvious which templates are full pages and which are fragments.
Live Search: 23 Lines of Python vs. 340 Lines of React
This is the feature that converted me. The client's dashboard had a search-as-you-type feature for finding projects. The React implementation:
SearchBarcomponent with debouncedonChangeuseSearchcustom hook withuseEffect,useState,useReffor abort controllerSearchResultscomponent with loading state, empty state, error statesearchApi.jsservice module with fetch wrapper and error handling- Redux action for caching recent searches
- Total: ~340 lines of JavaScript across 5 files
The HTMX implementation:
<!-- templates/projects/_search.html -->
<input type="search"
name="q"
placeholder="Search projects..."
hx-get="/projects/search/"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner"
autocomplete="off">
<div id="search-spinner" class="htmx-indicator">
<span class="spinner"></span>
</div>
<div id="search-results">
{% include "projects/_search_results.html" %}
</div># views.py
def project_search(request):
query = request.GET.get("q", "").strip()
if len(query) < 2:
return render(request, "projects/_search_results.html", {"projects": []})
projects = (
Project.objects
.filter(
Q(title__icontains=query) |
Q(description__icontains=query) |
Q(client__name__icontains=query)
)
.select_related("client")
[:20]
)
return render(request, "projects/_search_results.html", {
"projects": projects,
"query": query,
})That is it. Twenty-three lines of Python. The delay:300ms in hx-trigger handles debouncing. The hx-indicator handles the loading spinner. HTMX automatically cancels in-flight requests when a new one starts (so you get abort controller behavior for free).
I counted the React version three times because I could not believe the difference. The HTMX version is not just shorter -- it is easier to reason about. The data flow is: type in input, Django renders HTML, HTMX swaps it in. There is no intermediate JSON layer, no state synchronization, no re-render cycle.
Infinite Scroll: The Sentinel Pattern
The dashboard had a paginated project list that originally used React's intersection observer with Redux-managed pagination state. Here is how we did it with HTMX:
<!-- templates/projects/_project_cards.html -->
{% for project in projects %}
<div class="project-card">
<h3>{{ project.title }}</h3>
<p>{{ project.description|truncatewords:30 }}</p>
<span class="badge">{{ project.client.name }}</span>
</div>
{% endfor %}
{% if has_next %}
<!-- Sentinel div -- when this scrolls into view, load more -->
<div hx-get="/projects/?cursor={{ next_cursor }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-indicator="#load-more-spinner">
<div id="load-more-spinner" class="htmx-indicator">
Loading more...
</div>
</div>
{% endif %}# views.py
def project_list(request):
cursor = request.GET.get("cursor")
page_size = 20
projects = Project.objects.filter(is_active=True).order_by("-created_at")
if cursor:
# Cursor-based pagination -- more efficient than offset
cursor_date = parse_datetime(cursor)
projects = projects.filter(created_at__lt=cursor_date)
projects = list(projects[:page_size + 1])
has_next = len(projects) > page_size
projects = projects[:page_size]
next_cursor = projects[-1].created_at.isoformat() if has_next and projects else None
template = "projects/_project_cards.html" if request.htmx else "projects/project_list.html"
return render(request, template, {
"projects": projects,
"has_next": has_next,
"next_cursor": next_cursor,
})The trick is hx-swap="outerHTML" on the sentinel div. When HTMX loads the next page, the response includes a new sentinel div at the bottom (if there are more results). The old sentinel replaces itself with the new cards plus a new sentinel. It is turtles all the way down, and it just works.
The key detail: we use cursor-based pagination instead of offset-based. With offset pagination, if new items are added between page loads, you get duplicates. Cursor pagination avoids this entirely. This is not HTMX-specific advice -- it is good pagination advice period -- but HTMX's request pattern makes it trivial to implement.
Real-Time Updates with Server-Sent Events
This was the feature where I almost gave up on HTMX and reached for WebSockets. The dashboard needed live updates when other team members modified projects. I spent two weeks down the WebSocket rabbit hole -- Django Channels, Redis pub/sub, reconnection logic, message serialization -- before a colleague pointed out that Server-Sent Events (SSE) handle 90% of real-time use cases with 10% of the complexity.
HTMX 2.0 has built-in SSE support:
<!-- Connect to SSE endpoint and update elements when events arrive -->
<div hx-ext="sse"
sse-connect="/projects/stream/"
sse-swap="project-updated">
<div id="project-list">
{% include "projects/_project_cards.html" %}
</div>
</div># views.py
import json
from django.http import StreamingHttpResponse
async def project_stream(request):
async def event_stream():
last_check = timezone.now()
while True:
# Check for updates every 2 seconds
await asyncio.sleep(2)
updated = Project.objects.filter(
updated_at__gt=last_check,
team=request.user.team,
)
async for project in updated:
html = render_to_string(
"projects/_project_card.html",
{"project": project},
request=request,
)
yield f"event: project-updated\ndata: {json.dumps({'html': html})}\n\n"
last_check = timezone.now()
return StreamingHttpResponse(
event_stream(),
content_type="text/event-stream",
)SSE is one-directional (server to client), which is exactly what we needed. The client does not need to send real-time data to the server -- that is what forms and HTMX requests are for. If you genuinely need bidirectional real-time communication (chat, collaborative editing), then yes, use WebSockets. But for "show updates when things change," SSE is dramatically simpler.
The two weeks I spent on WebSockets before realizing SSE would work? That is a lesson I am sharing so you do not repeat it. Ask yourself: "Does the client need to push data to the server in real time, or just receive updates?" If it is just receiving, SSE is your answer.
The Rough Edges (Being Honest)
HTMX is not a silver bullet. Here is what gave us trouble:
Complex Client-Side State
A drag-and-drop kanban board with optimistic updates, undo support, and offline queuing? HTMX is the wrong tool. We kept one React micro-frontend for exactly this feature, embedded in the Django page via a <div id="kanban-root"> mount point. The rest of the page is HTMX. This hybrid approach works surprisingly well.
Template Sprawl
Our partial templates multiplied fast. We went from 15 templates to 47 after the migration. The _ prefix convention helps, but you need to be disciplined about extracting shared partials. We ended up with a directory structure like:
templates/
projects/
project_list.html # Full page
project_detail.html # Full page
_project_cards.html # Partial: card list
_project_card.html # Partial: single card
_project_form.html # Partial: create/edit form
_search_results.html # Partial: search results
_project_stats.html # Partial: stats widgetTesting HTMX Interactions
Testing full-page Django views is straightforward with TestClient. Testing HTMX partials requires checking that the right fragment is returned, not just that the status code is 200:
class ProjectSearchTest(TestCase):
def test_htmx_search_returns_partial(self):
ProjectFactory.create(title="Alpha Project")
ProjectFactory.create(title="Beta Project")
response = self.client.get(
"/projects/search/?q=alpha",
HTTP_HX_REQUEST="true", # Simulate HTMX request
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Alpha Project")
self.assertNotContains(response, "Beta Project")
# Verify it is a partial, not a full page
self.assertNotContains(response, "<html")
self.assertNotContains(response, "<!DOCTYPE")No Offline Support
If your app needs to work offline, HTMX is not the right choice. Every interaction requires a server round-trip. Service workers and IndexedDB can patch some of this, but at that point you are building a JavaScript app anyway.
Browser Back Button
HTMX partial swaps do not update the URL by default, so the browser back button does not work as expected. You need hx-push-url="true" on navigations that should be bookmarkable:
<a hx-get="/projects/{{ project.id }}/"
hx-target="#main-content"
hx-push-url="true">
{{ project.title }}
</a>We forgot this on the initial build and got a bug report within a day. Users expect the back button to work. Always.
Performance: The Numbers
Here is the comparison between the React SPA and the HTMX rebuild, measured on the same infrastructure:
| Metric | React SPA | Django + HTMX | Difference |
|---|---|---|---|
| Initial JS bundle | 847KB (231KB gzip) | 48KB (17KB gzip) | -94% |
| Time to Interactive (3G) | 4.2s | 1.1s | -74% |
| Time to Interactive (4G) | 1.8s | 0.6s | -67% |
| Lighthouse Performance | 62 | 94 | +52% |
| Lighthouse Accessibility | 78 | 96 | +23% |
| Build time | 45s (Vite) | 0s (no build step) | -100% |
| Deployment artifacts | 2 (API + SPA) | 1 (Django) | -50% |
| Lines of JavaScript | 12,400 | 340 (Alpine.js snippets) | -97% |
| API endpoints needed | 23 (REST) | 0 (views return HTML) | -100% |
The "zero API endpoints" line is slightly misleading -- the Django views are effectively endpoints, they just return HTML instead of JSON. But the point stands: we eliminated an entire layer of serialization, deserialization, and client-side rendering.
The metric that matters most to the client: page load time on their team's average connection dropped from 1.8 seconds to 0.6 seconds. Their team is distributed across India and Southeast Asia, where connection speeds vary wildly. Shipping less JavaScript has an outsized impact in these conditions.
The Decision Framework: When HTMX, When React
After building three HTMX projects and maintaining several React ones, here is how we decide:
Choose Django + HTMX when:
- The app is form-heavy (admin panels, dashboards, CRMs, internal tools)
- The team is primarily Django/Python developers
- SEO matters (server-rendered HTML is inherently SEO-friendly)
- The target audience has varying connection speeds
- You want a single deployment and a single codebase
- The interactivity is mostly "fetch and display" (search, filter, paginate, CRUD)
Choose React/Next.js when:
- The app has complex client-side state (collaborative editing, rich drag-and-drop)
- You need offline support
- The team is primarily JavaScript/TypeScript developers
- You are building a highly interactive consumer-facing product (think Figma, not Jira)
- Real-time bidirectional communication is core to the product
The hybrid approach (our recommendation for most teams):
- Use HTMX for 90% of pages
- Embed React/Vue micro-frontends for the genuinely complex interactive bits
- Mount JavaScript components into server-rendered pages via specific DOM elements
- Share authentication and routing through Django, let JavaScript handle isolated interactive widgets
This is not a cop-out -- it is genuinely the best approach for most applications. The era of "everything must be a SPA" is ending, and the era of "use the right tool for each page" is here.
This is the second post in our Django in 2026 series. Previously: Django 6.0's Tasks Framework. Next up: Async Django in 2026 -- an honest assessment.
Building a Django application and wondering whether HTMX could simplify your frontend? At CODERCOPS we have been shipping Django projects for years -- from pure server-rendered apps to hybrid HTMX/React architectures. Reach out and we will give you an honest recommendation for your specific use case, or browse our other technical deep dives for more production-tested patterns.
Comments