Interactive Helpdesk Demo & Empirical Benchmark Suite

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.

Target-only HTMX State Drift

Single-target swap (#main-content)

Breadcrumbs: Stale (Page 1)
Sidebar: Stale (Page 1)
Main Content: Updated (Page 2)

Fixing it by hand has a price

django-htmx-nav: describe the regions, skip the plumbing

Python Drop-in extension of render()
render_nav(
    request,
    "homepage.html",
    swaps=[
        Swap("_sidebar.html", target_id="sidebar"),
        Swap("_breadcrumbs.html", target_id="breadcrumbs"),
    ],
)
django-htmx-nav Synchronized

Same click, one response

Breadcrumbs: Synced (Page 2)
Sidebar: Synced (Page 2)
Main Content: Updated (Page 2)

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.

Baseline Full reload

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
Manual HTMX Hand-rolled OOB

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
Recommended
Helper Library Declarative

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
Empirical Data

Benchmark Highlights

Measured across reproducible testbeds with Playwright and server instrumentation.

Open Full Benchmark Suite
~32%
Payload Reduction
Average wire size reduction compared to views unaware vanilla HTMX.
<0.5 ms
Render Overhead
Minimal server-side compute cost for building multi-region OOB swaps.
4.5 avg
Flat DB Queries
Request-scoped caching prevents duplicate queries across partials.
23
Tested Variants
Comprehensive comparison across MPA, Vanilla HTMX, hx-select, and Idiomorph.

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.