Server-Driven Navigation for HTMX in Django
Keep sidebars, breadcrumbs, and active navigation controls synchronized with the URL as the single source of truth without state drift or boilerplate.
pip install django-htmx-nav
The Stale Navigation Problem
When an HTMX request updates only an inner container, everything around it stays as it was.
Single-target swap (#main-content)
Fixing it by hand has a price
hx-select / morph
Bigger payloads
The full page is sent on every click.
Branching in templates
Logic lives in markup
Request checks and OOB wrappers, with more branches per nesting level.
Hand-built OOB in views
Boilerplate
Hand-joined strings, and it's easy to forget a region.
HX-Trigger + re-fetch
Extra requests
Each region re-fetches itself after the content arrives.
django-htmx-nav:
describe the regions, skip the plumbing
render()
render_nav(
request,
"homepage.html",
swaps=[
Swap("_sidebar.html", target_id="sidebar"),
Swap("_breadcrumbs.html", target_id="breadcrumbs"),
],
)
Same click, one response
Direct visits render the full page. HTMX requests receive the
content partial plus the declared swaps.
Component templates stay ordinary, with no OOB wrappers inside them.
3 Architectural Approaches Compared
The same Helpdesk app, built 8 ways across 23 controlled variants. Compare developer experience, code complexity, and wire payload side by side.
Pure Multi-Page App (MPA)
Traditional full-page reloads on every navigation. Simplest conceptual model with zero client-side coordination, but transfers full layout HTML on every click.
- ✓ Native browser navigation, nothing to learn
- ✓ Zero client state to keep in sync
- ✗ ~32% more bytes on the wire per navigation
Vanilla HTMX
Standard HTMX with hand-written out-of-band fragments. Every view checks HX-Target itself and assembles the right partials. Complex swaps may require branching logic inside Django templates.
- ✓ Fast partial updates on the wire
- ✗ Verbose template branching and string joining
- ✗ Risk of state drift if an OOB region is forgotten
django-htmx-nav
render_nav serves as a drop-in partial renderer with full-page fallback, while Swap declaratively auto-wraps ordinary templates to sync surrounding UI out-of-band.
- ✓ Same ~32% payload reduction as hand-written OOB
- ✓ Drop-in replacement for Django's
render() - ✓ Zero template pollution and visual debugger helpers
Benchmark Highlights
Measured across reproducible testbeds with Playwright and server instrumentation.
From One View to a Whole App
Define the shell once, reuse it in function and class-based views, and use the same
Swap for updates that aren't navigation.
from django.shortcuts import get_object_or_404
from htmx_nav import Swap, make_shell_renderer
# Defined once, reused by every view in the app
render_shell = make_shell_renderer(lambda request: [
Swap("components/_sidebar.html", {"user": request.user}, target_id="sidebar"),
Swap("components/_breadcrumbs.html", target_id="breadcrumbs"),
])
def project_board(request, project_id):
project = get_object_or_404(Project, id=project_id)
# Sidebar & breadcrumbs come along for free, every time
return render_shell(
request,
"pages/board.html",
{"project": project},
extra_swaps=Swap(
"components/_status_badge.html",
target_id="project-status",
),
)
from django.views.generic import DetailView
from htmx_nav import Swap, make_shell_view_mixin
# Built from the same `render_shell` defined for function-based views
ShellViewMixin = make_shell_view_mixin(render_shell)
class ProjectBoardView(ShellViewMixin, DetailView):
model = Project
template_name = "pages/board.html"
context_object_name = "project"
def get_extra_swaps(self):
# Sidebar & breadcrumbs come from the shell; extras go here
return Swap(
"components/_status_badge.html",
target_id="project-status"
)
from django.contrib import messages
from django.shortcuts import get_object_or_404
from htmx_nav import Swap, has_messages, render_nav
def delete_ticket(request, ticket_id):
ticket = get_object_or_404(Ticket, id=ticket_id)
ticket.delete()
messages.success(request, f"Ticket #{ticket_id} deleted.")
# No navigation involved, just OOB updates alongside the response
return render_nav(
request,
"tickets/empty_state.html",
partial=None,
swaps=[
Swap.delete(f"ticket-row-{ticket_id}"),
Swap.text(
"open-tickets-count",
str(Ticket.objects.filter(status="open").count()),
),
Swap(
"components/_messages.html",
target_id="messages",
include_if=has_messages,
),
],
)
Declare the sidebar and breadcrumbs once. Every view that uses render_shell gets them, and extra_swaps adds view-specific regions.
The same shell works with Django's generic views through a mixin.
Swap isn't only for navigation. Delete rows, update counters, and flush messages alongside any response.
Ready to eliminate state drift in your Django apps?
Get up and running in under five minutes. Install the package, drop in your first layout shell, and keep your HTMX navigation completely in sync.