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 |
|
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.