Contents Menu Expand Light mode Dark mode Auto light/dark, in light mode Auto light/dark, in dark mode Skip to content
django-htmx-nav
django-htmx-nav

Getting Started & Guides

  • Quickstart Guide
  • Example Project & Reference Testbed
  • Navigation Patterns

Operations & Testing

  • Testing
  • Visual Swap Debugging

Reference

  • API Reference
  • Glossary
Back to top
View this page

Navigation Patterns¶

Supplying a swaps builder to make_shell_renderer.

make_shell_renderer exists to stop every view from re-declaring the same sidebar/breadcrumb Swaps. It does this by baking a swaps builder function into a render_shell closure, so each view just calls render_shell(...) and the nav data comes along for free.

This page is about how to construct those shell swaps — the two patterns below (a Python registry, or Django’s own template-tag system) are the two ends of the spectrum, and most projects land on one or a blend of both.

You don’t need this at all¶

make_shell_renderer is a convenience layer, not a requirement. render_nav (which it wraps) already does everything needed to swap a sidebar or breadcrumb region out-of-band:

from htmx_nav import Swap, render_nav


def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)
    return render_nav(
        request,
        "app/project_detail.html",
        {"project": project},
        swaps=[
            Swap("app/_sidebar.html", {"active": project.pk}, target_id="sidebar"),
            Swap(
                "app/_breadcrumbs.html", {"project": project}, target_id="breadcrumbs"
            ),
        ],
    )

That’s a perfectly valid way to use the package. The tradeoff is purely about repetition: every view that touches shared nav has to remember to build and pass those Swaps itself, and if you forget on view #12, its sidebar goes stale on HTMX navigation. make_shell_renderer exists only to centralize that so you can’t forget it, it trades a small amount of upfront structure (a swaps builder function, and possibly one of the two patterns below) for never having to think about it again in a view. If your project has two or three views and a static sidebar, that trade often isn’t worth making, use render_nav directly and stop reading here.

If you do want that centralization, keep reading.

The contract¶

render_shell = make_shell_renderer(
    lambda request: [
        Swap(
            "yourapp/_sidebar.html",
            build_sidebar_context(request),
            target_id="sidebar",
        ),
        Swap(
            "yourapp/_breadcrumbs.html",
            build_crumbs_context(request),
            target_id="breadcrumbs",
        ),
    ]
)

The swaps builder is either a static list of Swaps or any Callable[[HttpRequest], Swaps]. It is evaluated once per request (full page load or HTMX), and its returned Swap instances accompany the shell response. Any per-call extra_swaps provided by a specific view are merged alongside them. Everything below is advice on what nav helpers should look like and where their data should live.

Pattern A: Centralized registry¶

A hand-rolled registry of navigation data in one Python module. Best when the project has enough views that “what does the sidebar look like from here” needs a single, auditable source of truth.

Navigation registry¶

# yourapp/nav.py
from dataclasses import dataclass, field
from typing import Callable, Optional, Sequence, Union

from django.http import HttpRequest
from django.urls import reverse

from htmx_nav.helpers import cache_on_request

Resolvable = Union[str, Callable[[HttpRequest], str]]


def _resolve(value: Optional[Resolvable], request: HttpRequest) -> Optional[str]:
    if value is None:
        return None
    return value(request) if callable(value) else value


@dataclass(frozen=True)
class Link:
    label: Resolvable
    view_name: str
    icon: str = ""


@dataclass(frozen=True)
class Crumb:
    label: Resolvable
    url: Optional[Resolvable] = None  # None = current page, not linked


@dataclass(frozen=True)
class NavState:
    """Navigation state for a given view."""

    active_link: str = ""
    breadcrumbs: Sequence[Crumb] = field(default_factory=tuple)


# Project-specific navigation data
SIDEBAR = [
    Link("Dashboard", "app:dashboard", ICON_DASHBOARD),
    Link("Projects", "app:project_list", ICON_PROJECTS),
]

NAV_STATES: dict[str, NavState] = {
    "app:dashboard": NavState(
        active_link="app:dashboard", breadcrumbs=[Crumb("Dashboard")]
    ),
    "app:project_list": NavState(
        active_link="app:project_list",
        breadcrumbs=[Crumb("Projects")],
    ),
    "app:project_detail": NavState(
        active_link="app:project_list",
        breadcrumbs=[
            Crumb("Projects", lambda r: reverse("app:project_list")),
            Crumb(lambda r: r.project.name),
        ],
    ),
}


def build_nav_context(request: HttpRequest) -> dict:
    def _build():
        match = request.resolver_match
        view_name = match.view_name if match else ""
        state = NAV_STATES.get(view_name, NavState())

        sidebar = [
            {
                "label": _resolve(link.label, request),
                "url": reverse(link.view_name),
                "icon": link.icon,
                "active": link.view_name == state.active_link,
            }
            for link in SIDEBAR
        ]
        breadcrumbs = [
            {
                "label": _resolve(crumb.label, request),
                "url": _resolve(crumb.url, request),
            }
            for crumb in state.breadcrumbs
        ]
        return {"sidebar": sidebar, "breadcrumbs": breadcrumbs}

    return cache_on_request(request, "_yourapp_nav", _build)

Wiring up Pattern A¶

# yourapp/render.py
from htmx_nav import make_shell_renderer

from .nav import build_nav_context

render_shell = make_shell_renderer(
    shell_template="yourapp/_shell.html",
    context_builder=lambda request: {"nav": build_nav_context(request)},
)
# yourapp/views.py
from .render import render_shell


def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)
    request.project = project  # needed for the lambda-based crumb above
    return render_shell(request, "yourapp/project_detail.html", {"project": project})

Testing Pattern A¶

Because the registry returns plain dicts, you can assert against them directly, without rendering templates:

from htmx_nav.testing import assert_shell_parity


def test_project_detail_nav_parity(client, project):
    assert_shell_parity(
        client,
        f"/projects/{project.pk}/",
        checks={
            "active_sidebar_item": lambda ctx: [
                i["label"] for i in ctx["nav"]["sidebar"] if i["active"]
            ],
            "breadcrumbs": lambda ctx: [c["label"] for c in ctx["nav"]["breadcrumbs"]],
        },
    )

Extension points¶

  • Add fields to Link/NavState (e.g. visible_if: Callable, extra: dict)

  • Support multiple structures at once (navbar and sidebar)

  • Add conditional visibility based on request state

  • Attach arbitrary metadata to nav items

Escape hatch: inline breadcrumbs for one-off views¶

The registry doesn’t have to own every view’s breadcrumbs. A view that doesn’t fit the general shape can just pass its own:

from django.urls import reverse


def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)
    breadcrumbs = [
        {"label": "Projects", "url": reverse("app:project_list")},
        {"label": project.name, "url": None},
    ]
    return render_shell(
        request,
        "yourapp/project_detail.html",
        {"project": project, "breadcrumbs": breadcrumbs},
    )

Pattern B: Context processors + template tags¶

Leans on Django’s own template-tag system instead of a parallel data structure — navigation markup lives next to the HTML it renders. Best for small, mostly-static navigation where a registry would just be indirection.

Template tags¶

# yourapp/templatetags/nav_tags.py
from django import template
from django.urls import reverse

register = template.Library()


@register.simple_tag(takes_context=True)
def nav_link(context, view_name, label, css_class="nav-link"):
    request = context["request"]
    is_active = request.resolver_match and request.resolver_match.view_name == view_name
    url = reverse(view_name)
    active_class = f" {css_class}--active" if is_active else ""
    return f'<a href="{url}" class="{css_class}{active_class}">{label}</a>'


@register.simple_tag(takes_context=True)
def nav_crumb(context, label, view_name=None, *args, **kwargs):
    if not view_name:
        return f'<span class="crumb crumb--current">{label}</span>'
    url = reverse(view_name, args=args, kwargs=kwargs)
    return f'<a href="{url}" class="crumb">{label}</a>'

Shell template¶

{# yourapp/templates/yourapp/_shell.html #}
{% load nav_tags %}
<nav id="sidebar" hx-swap-oob="true">
  {% nav_link "app:dashboard" "Dashboard" %}
  {% nav_link "app:project_list" "Projects" %}
</nav>
<div id="breadcrumbs" hx-swap-oob="true">
  {% block breadcrumbs %}{% endblock %}
</div>

Per-view breadcrumbs override the block:

{# yourapp/templates/yourapp/project_detail.html #}
{% extends "yourapp/_shell.html" %}
{% load nav_tags %}

{% block breadcrumbs %}
  {% nav_crumb "Projects" "app:project_list" %}
  {% nav_crumb project.name %}
{% endblock %}

{% partialdef content %}
  ...
{% endpartialdef %}

Context processors for the rest¶

Anything that isn’t rendered via a tag (e.g. data every template needs regardless of nav) can ride in on a normal context processor instead of context_builder:

# yourapp/context_processors.py
def workspace(request):
    return {"active_workspace": get_active_workspace(request)}
# settings.py
TEMPLATES = [{
    ...,
    "OPTIONS": {
        "context_processors": [
            ...,
            "yourapp.context_processors.workspace",
        ],
    },
}]

Wiring up pattern B¶

Note context_builder is optional here. If template tags and context processors cover everything, make_shell_renderer needs nothing more than the shell template itself:

from htmx_nav import make_shell_renderer

render_shell = make_shell_renderer(shell_template="yourapp/_shell.html")


def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)
    return render_shell(request, "yourapp/project_detail.html", {"project": project})

Testing pattern B¶

Parity here is verified by rendering, not by asserting against plain data:

from htmx_nav.testing import assert_shell_parity


def test_navigation_parity(client):
    assert_shell_parity(client, "/some-url/")

Choosing between them¶

Registry (A)

Template tags (B)

Nav structure lives in

Python dataclasses

HTML/templates

context_builder needed?

Yes, always

Often not at all

Scales to many views

Well, one file to audit

Poorly, logic spreads across templates

Testable without rendering

Yes, plain dicts

No, requires HTML rendering

Upfront cost

Higher (schema + registry)

Lower (uses tags you already know)

Conditional/complex nav logic

Straightforward

Awkward, pushes logic into templates

Use the registry if the project has enough views that you want one place to audit “what nav state does view X produce,” or if breadcrumb relationships are non-trivial and worth centralizing.

Use template tags if navigation is small and mostly static, and a parallel Python data structure would just be indirection over what the templates already say.

Nothing stops you from mixing them, e.g. a registry for breadcrumbs (where relationships matter) and template tags for a static sidebar (where they don’t). Both patterns build on the same make_shell_renderer(context_builder=...) seam, so switching later, or combining them, doesn’t touch your views.

And if none of this earns its keep yet, remember the option from the top of this page: skip make_shell_renderer entirely and pass Swaps to render_nav per view. Reach for one of these patterns only once that repetition actually starts to hurt.

Next
Testing
Previous
Example Project & Reference Testbed
Copyright © 2026, Lucas Rollin Ferreira
Made with Sphinx and @pradyunsg's Furo
On this page
  • Navigation Patterns
    • You don’t need this at all
    • The contract
    • Pattern A: Centralized registry
      • Navigation registry
      • Wiring up Pattern A
      • Testing Pattern A
      • Extension points
      • Escape hatch: inline breadcrumbs for one-off views
    • Pattern B: Context processors + template tags
      • Template tags
      • Shell template
      • Context processors for the rest
      • Wiring up pattern B
      • Testing pattern B
    • Choosing between them