Skip to main content

django-htmx-nav

Docs

Django 6 added native template partials, so a single file can define both the full page and the fragment HTMX swaps into:

{% extends 'base.html' %}

{% block content %}
{% partialdef content inline %}
  <div>My page!</div>
{% endpartial %}
{% endblock %}

That's most of what you need for server-driven, SPA-like UX with MPA simplicity, the URL stays the source of truth, and a full load vs. an HTMX swap render the same fragment. What's still missing is the boilerplate around it: detecting HTMX, picking the partial, setting HX-Push-Url and doing HTMX-safe redirects. And, once your page has more than one region (a sidebar, breadcrumbs), keeping those regions from drifting out of sync depending on how the page was reached.

django-htmx-nav is two functions for that. Not a Django app, nothing to add to INSTALLED_APPS.

pip install django-htmx-nav

render_htmx: partial rendering, done

from htmx_nav.responses import render_htmx

def project_list(request):
    return render_htmx(request, "app/project_list.html", {"projects": Project.objects.all()})
{% extends 'base.html' %}
{% block content %}
{% partialdef content inline %}
  {% for project in projects %}<div>{{ project.name }}</div>{% endfor %}
{% endpartial %}
{% endblock %}

Full page load → renders the whole template. HTMX request → renders only the content partial, sets HX-Push-Url to the current URL (so the swap is bookmarkable and reload-safe), and sets Vary: HX-Request so caches never serve one variant to the other kind of request.

But you likely need to update your sidebar too

A tab click swaps #content, but if the sidebar shows an active-item highlight, or breadcrumbs, those live outside #content and won't update on their own. HTMX's out-of-band swaps solve this: render extra fragments alongside the main one, each targeting its own DOM id.

from htmx_nav.responses import Swap, render_htmx

def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)
    return render_htmx(
        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"),
        ],
    )

This works, but every view that touches the sidebar now needs to rebuild the same nav context and remember to pass the same two Swaps, easy to forget in view #12.

make_shell_renderer: the shell, abstracted away

make_shell_renderer bakes a shell template + its context into a render_shell function, so call sites go back to looking like a plain view — the shell just always comes along for free:

from htmx_nav.responses import make_shell_renderer

render_shell = make_shell_renderer(
    shell_template="app/_shell.html",  # renders sidebar + breadcrumbs together
    context_builder=lambda request: {"nav": build_nav_context(request)},
)

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

Every HTMX response from render_shell includes the shell as an out-of-band swap, built from the same build_nav_context a full page load would use, so the sidebar can never show one thing on first load and another after an HTMX swap. render_shell accepts the same keyword arguments as render_htmx (extra_swaps, partial_name, push_url, ...), so it's a drop-in.

Advanced: three-pathway views (e.g. intrapage navigtion with tabs)

Some views have more than two ways they can be entered: a full reload, an HTMX navigation into the page from elsewhere (targeting the page's own top-level container), and an HTMX swap within the page (e.g. clicking a tab). The first two should render the whole "content" region and skip any extra OOB fragments beyond the shell; only the third needs extra_swaps for siblings like a tab strip. page_target_id tells render_shell how to fold the first two together automatically:

render_staff = make_shell_renderer(
    "app/_shell.html",
    context_builder=lambda request: {"nav": build_nav_context(request)},
    page_target_id="page-content",   # DOM id of the page's own container
)

def project_tab(request, pk, *, tab_partial_name):
    project = get_object_or_404(Project, pk=pk)
    return render_staff(
        request, "app/project_detail.html", {"project": project},
        partial_name=tab_partial_name,
        extra_swaps=[Swap("app/_tabs.html", {"active": tab_partial_name}, target_id="tabs")],
    )
  • Full reload / HTMX targeting #page-content: render_staff overrides partial_name to "content" and drops extra_swaps down to just the shell — nothing else in the DOM exists yet to refresh.
  • Any other HTMX target (a tab click, a modal retargeted to a tab): falls through to partial_name=tab_partial_name and the tab strip swap, exactly as passed.

The view stays focused on what's actually tab-specific (which partial, which sibling needs refreshing); the "which of the 3 pathways is this" branching lives once, inside render_shell.

Also included

  • htmx_redirect(request, url)HX-Redirect + 204 on HTMX requests, a normal redirect otherwise. A plain HttpResponseRedirect gets swapped into the DOM as raw HTML instead of navigating, which is rarely what you want.
  • htmx_nav.testing.assert_shell_parity — hits a URL under several request shapes (full reload, each HTMX pathway) and asserts your nav context is identical across all of them — turns "stale nav state" into something you can catch in CI instead of by eyeballing.

⚠️ This package is young. The API (function signatures, keyword argument names, module layout) may still change between releases.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_htmx_nav-0.2.0.tar.gz (11.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_htmx_nav-0.2.0-py3-none-any.whl (9.2 kB view details)

Uploaded Python 3

File details

Details for the file django_htmx_nav-0.2.0.tar.gz.

File metadata

  • Download URL: django_htmx_nav-0.2.0.tar.gz
  • Upload date:
  • Size: 11.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_htmx_nav-0.2.0.tar.gz
Algorithm Hash digest
SHA256 05639d6247e97c7877bc36ec332cd6288a46e155ca277c386c71fb7069e3c607
MD5 c3b368a0b9390b2d48d8eb59feaba5e5
BLAKE2b-256 6efbf913709d4acf8a639ca35fb67b318c52c359c0ae6a55f75d07adab5e363f

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_htmx_nav-0.2.0.tar.gz:

Publisher: python-publish.yml on lucas-rollin/django-htmx-nav

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_htmx_nav-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_htmx_nav-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d16c79986a5bdc489bc230c762431685dba896876bd626d118e6860c9c91e70b
MD5 b5079a6ec686c4ec6c7980e12adad58c
BLAKE2b-256 015b43868af36e2e2d5af3e665f2bffcded2ce3e845be27d7ecc091b39cfa0c5

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_htmx_nav-0.2.0-py3-none-any.whl:

Publisher: python-publish.yml on lucas-rollin/django-htmx-nav

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.3.1

2 files

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page