djust docs
Browse documentation
intermediate

Navigation & URL State

SPA-like navigation with live_patch, live_redirect, and dj-navigate directives

On this page

djust provides live_patch() and live_redirect() for managing URL state without full page reloads, inspired by Phoenix LiveView. Bookmark-friendly URLs, browser back/forward, and deep linking all work out of the box.

What You Get

  • live_patch() -- Update URL query params without remounting the view
  • live_redirect() -- Navigate to a different LiveView over the existing WebSocket
  • Template directives -- dj-patch and dj-navigate for declarative navigation
  • Browser history -- Full back/forward support via popstate handling

Quick Start

1. Subclass LiveView (navigation is built in)

NavigationMixin is already included in LiveView, so its navigation helpers are always available — just subclass LiveView. Do not also inherit NavigationMixin (class ProductListView(NavigationMixin, LiveView)): it is already in LiveView's bases, so that raises TypeError: Cannot create a consistent method resolution.

from djust import LiveView
from djust.decorators import event_handler

class ProductListView(LiveView):
    template_name = 'products/list.html'

    def mount(self, request, **kwargs):
        self.category = "all"
        self.page = 1
        self.products = []

    def handle_params(self, params, uri):
        """Called when URL params change (live_patch or browser back/forward)."""
        self.category = params.get("category", "all")
        self.page = int(params.get("page", 1))
        self.products = self.fetch_products()

    @event_handler()
    def filter_by_category(self, category="all", **kwargs):
        self.live_patch(params={"category": category, "page": 1})

2. Use Navigation Directives

<!-- Update URL params without remount -->
<a dj-patch="?category=electronics&page=1">Electronics</a>
<a dj-patch="?category=books&page=1">Books</a>

<!-- Navigate to a different view -->
<a dj-navigate="/products/{{ product.id }}/">View Details</a>

API Reference

live_patch(params=None, path=None, replace=False)

Update the browser URL without remounting the view. Triggers handle_params() and a re-render. mount() is NOT called again.

# Update query params only
self.live_patch(params={"page": 2})

# Change path and params
self.live_patch(path="/search/", params={"q": "django"})

# Replace current history entry (no back button entry)
self.live_patch(params={"sort": "price"}, replace=True)

live_redirect(path, params=None, replace=False)

Navigate to a different LiveView over the existing WebSocket. The current view is unmounted and the new view is mounted fresh.

self.live_redirect("/items/42/")
self.live_redirect("/search/", params={"q": "widgets"})

handle_params(params, uri)

Callback invoked when URL params change. Override this to update view state based on the URL.

def handle_params(self, params, uri):
    self.category = params.get("category", "all")
    self.page = int(params.get("page", 1))
    self.results = self.search(self.category, self.page)

Template Directives

dj-patch

Declarative live_patch. Updates the URL and sends url_change to the server without remounting.

<a dj-patch="?sort=name&order=asc">Sort by Name</a>
<a dj-patch="/products/?category=new">New Products</a>
<a dj-patch="/">Home (root path)</a>

Patching to the root path / is supported and correctly updates the browser URL.

Note: Use dj-patch for navigation instead of dj-click when you need URL updates and browser history support. System check djust.T010 will warn if you use dj-click with navigation-related data attributes like data-view or data-tab.

dj-navigate

Declarative live_redirect. SPA-navigates to a different LiveView over the existing WebSocket — no socket teardown, no full page reload. (A full reload happens only as a fallback when the target path isn't in the route map.)

<a dj-navigate="/dashboard/">Go to Dashboard</a>
<a dj-navigate="/items/{{ item.id }}/">View Item</a>

Zero wiring required

dj-navigate works out of the box — no live_session() needed. The client route map (URL path → LiveView) is auto-derived from your Django URLconf (every route whose view subclasses djust.LiveView) and auto-emitted by {% djust_client_config %}, the tag that's already in every scaffolded base <head>. As long as your base template loads {% djust_client_config %} and your LiveViews are wired into urlpatterns, SPA navigation just works.

{% load live_tags %}
<head>
    {% djust_client_config %}  {# emits the API/SSE prefixes + the route map #}
</head>

If dj-navigate is used but no LiveView routes are found in the URLconf (so the route map is empty), djust's system check djust.T016 warns you — otherwise dj-navigate would silently fall back to a full page reload.

live_session() is still supported for grouping views that share a WebSocket connection, and its registrations are merged into the auto-derived map. It is no longer required just to make dj-navigate resolve routes.

A persistent nav usually lives outside [dj-root], so dj-navigate's dj-root-only swap won't update a server-rendered "active page" class on navigation. djust handles this for you: after every navigation (click, WS mount, and browser back/forward) it sets aria-current="page" on the [dj-navigate] link whose path matches the current URL and removes it from the others. Style the active link off that attribute — no per-app JavaScript needed:

<a dj-navigate="/">Home</a>
<a dj-navigate="/docs/">Docs</a>

<style>
  a[dj-navigate][aria-current="page"] { font-weight: 700; color: #fff; }
</style>

Cross-origin dj-navigate targets (e.g. a link to a sister site) are never marked current, and an aria-current you set yourself to a different value (step, true, …) is left untouched. Matching is exact-path; for section/ancestor highlighting (e.g. /docs/ active on /docs/guides/x), add your own rule on top.

What the back button does

Going back has two meanings and djust has to tell them apart. Back to a different path is a different view, so it re-mounts. Back to the same path with different query parameters is the same view seeing new parameters, so it sends handle_params and keeps your scroll position, your inputs and your view state.

The pathname is what decides it. djust records the path the mounted view is showing and compares on popstate, because popstate fires after the address bar has already changed and cannot otherwise see where it came from.

djust does not write to the history entry your page was loaded on. An earlier version did, and it broke dj-patch: a patch pushes an entry that is not a redirect, so backing out of a patched URL onto a stamped entry re-mounted the view and threw away exactly the state the patch path exists to preserve.

A live_redirect still marks its own entries, so an explicit redirect to the same path re-mounts as you asked.

When NOT to use dj-navigate

dj-navigate swaps the contents of [dj-root]. Everything outside it — the <head> above all — belongs to the document the reader already has. So a target page that needs its own stylesheet or script in <head> arrives as unstyled markup: the HTML is right, the CSS that makes it a page was never loaded, and nothing errors.

Link to such a page with a plain href, so the browser fetches the whole document:

{# Same application, same base template: SPA navigation is right. #}
<a dj-navigate="/dashboard/">Dashboard</a>

{# A page that brings its own head assets — another app's LiveViews, a
   section with its own stylesheet. A real navigation, deliberately. #}
<a href="/components/">Components</a>

The symptom that names this mistake: the destination renders with no styling, and nothing errors.

What live navigation updates, and what it keeps

Part of the destination pageOn dj-navigate / live_redirect
Everything inside [dj-root]Replaced
<title>Updated (since 1.2.1, see below)
<head> stylesheets, scripts, <meta>Kept from the previous page
Scripts outside [dj-root] (an {% block extra_scripts %})Kept from the previous page; the destination's never run
Inline <script> inside [dj-root]Inserted, never executed

The title follows the destination in this order:

  1. self.page_title, when the view sets it (in mount() or later). It always wins.
  2. Otherwise the <title> of the view's page template, {% block title %} included, rendered with the view's values. djust leaves the title unchanged when that <title> uses {{ block.super }} or reads a variable the view does not hold (a context-processor value such as site_name); set page_title in those cases.

Anything else that differs per page outside [dj-root] (a page-specific stylesheet, an extra_scripts block) needs a full load: link to that page with a plain href. Diffing <head> assets on live navigation is planned for 1.3 (#3036).

Chart.js / map blank after dj-navigate? Scripts in SPA-patched content don't execute, so an inline <script> that inits a library renders on a hard reload but stays blank after navigation. Initialize third-party libraries from a client hook registered once in your base template, not an inline <script>.

With dj-navigate you annotate each link. auto_navigate goes one step further: a single delegated click listener SPA-navigates plain <a href> links — no djust attribute needed — whenever the link's path resolves in the route map.

As of v1.1 this is ON by default — native dj-navigate is djust's canonical SPA-navigation model, so plain in-app links are SPA-navigated with zero config (the route map is auto-emitted). It degrades gracefully (see the fall-through list below), so existing apps keep working. To opt out (e.g. you wire your own external TurboNav), set it to False:

LIVEVIEW_CONFIG = {"auto_navigate": False}

On djust 1.0.x this was opt-in (auto_navigate defaulted to False); set it to True there to get the same behavior.

While it is on, {% djust_client_config %} emits a small flag and the client intercepts in-app navigations automatically. It is deliberately conservative — a link falls through to a normal browser navigation (no interception) whenever any of these hold:

  • a modifier/middle click (⌘/Ctrl/Shift/Alt, or a non-left button) — i.e. new tab/window
  • a target other than _self, a download attribute, or rel="external"
  • the link or any ancestor has data-no-navigate
  • the href is external (different origin) or a non-http(s) scheme (mailto:, tel:)
  • a same-page hash-only jump (#section) — the browser scrolls instead
  • the path isn't a LiveView route in the route map — admin pages, plain Django views, and routes the current user can't access just reload normally

Same-view query-only changes use live_patch (state-preserving); cross-view uses live_redirect. Because the route map is auth-filtered, auto_navigate never intercepts a route the current user isn't authorized for — it full-reloads and the server enforces access.

Opt a single link out with data-no-navigate:

<a href="/reports/" data-no-navigate>Force a full reload</a>

auto_navigate is on by default and changes the behavior of every in-app link, so the fall-through list above is the contract. Opt out with LIVEVIEW_CONFIG = {"auto_navigate": False}. dj-navigate remains the explicit, per-link form.

Example: Search with URL State

class SearchView(LiveView):  # navigation is built in — no NavigationMixin needed
    template_name = 'search.html'

    def mount(self, request, **kwargs):
        self.query = ""
        self.sort = "relevance"
        self.results = []

    def handle_params(self, params, uri):
        self.query = params.get("q", "")
        self.sort = params.get("sort", "relevance")
        if self.query:
            self.results = Product.objects.filter(
                name__icontains=self.query
            ).order_by(self.sort)

    @event_handler()
    def search(self, value="", **kwargs):
        self.live_patch(params={"q": value, "sort": self.sort})

    @event_handler()
    def change_sort(self, sort="relevance", **kwargs):
        self.live_patch(params={"q": self.query, "sort": sort})
<input type="text" dj-change="search" value="{{ query }}">

<div class="sort-options">
    <a dj-patch="?q={{ query }}&sort=relevance">Relevance</a>
    <a dj-patch="?q={{ query }}&sort=price">Price</a>
    <a dj-patch="?q={{ query }}&sort=-created">Newest</a>
</div>

{% for product in results %}
    <div class="product">
        <h3><a dj-navigate="/products/{{ product.id }}/">{{ product.name }}</a></h3>
        <p>${{ product.price }}</p>
    </div>
{% endfor %}

When to Use Patch vs Redirect

Use live_patch()Use live_redirect()
Filtering, sorting, paginatingNavigating to a different page
Changing tabs within the same viewMoving between list and detail views
Updating search parametersRedirecting after form submission
You want mount() NOT called againYou need a fresh mount() call

Best Practices

⚠️ Anti-Pattern: Don't Use dj-click for Navigation

This is the most common mistake when building multi-view djust apps. Using dj-click to trigger a handler that immediately calls live_redirect() creates an unnecessary round-trip.

❌ Wrong — using dj-click to trigger a handler that calls live_redirect():

# Anti-pattern: Handler does nothing but navigate
@event_handler()
def go_to_item(self, item_id, **kwargs):
    self.live_redirect(f"/items/{item_id}/")  # Wasteful round-trip!
<!-- Wrong: Forces WebSocket round-trip just to navigate -->
<button dj-click="go_to_item" dj-value-item_id="{{ item.id }}">View</button>

✅ Right — using dj-navigate directly:

<!-- Right: Client navigates immediately, no server round-trip -->
<a dj-navigate="/items/{{ item.id }}/">View Item</a>

Why it matters: Direct navigation is 10-20x faster (~10ms vs 110-250ms), saves WebSocket bandwidth, and provides instant user feedback.

When to Use live_redirect() in Handlers

Use handlers for navigation only when navigation depends on server-side logic:

  • Conditional navigation after form validation
  • Navigation based on auth/permissions checks
  • Navigation after async operations (creating records, API calls)
  • Multi-step wizard logic with conditional flow

Common theme: The handler does meaningful work before navigating. If your handler only calls live_redirect(), use dj-navigate instead.

Anti-Pattern: Don't Use dj-click for Tab/View Switching

Using dj-click with data attributes like data-view or data-tab to switch between sections within a view is fragile and loses URL state. Use dj-patch instead.

The anti-pattern:

<button dj-click="switch_view" data-view="settings">Settings</button>
@event_handler()
def switch_view(self, view="", **kwargs):
    self.active_view = view
    self._load_data()

Why this breaks:

  1. Data attributes are fragile -- if the VDOM diff replaces the element mid-click, or the user clicks a child element (e.g. an icon <span> inside the button), the view param can arrive as "", leaving the UI in a broken state.
  2. No URL update -- the browser URL doesn't change, so back/forward doesn't work, tabs aren't bookmarkable, and refreshing always resets to the default view.
  3. Race conditions -- if handle_tick fires between the click and the re-render, state can get out of sync because there's no URL as source of truth.

The correct pattern:

<a dj-patch="?tab=settings"
   class="{% if active_tab == 'settings' %}active{% endif %}">
    Settings
</a>
<a dj-patch="?tab=overview"
   class="{% if active_tab == 'overview' %}active{% endif %}">
    Overview
</a>
class DashboardView(LiveView):  # navigation is built in — no NavigationMixin needed
    template_name = 'dashboard.html'
    VALID_TABS = {"overview", "settings", "logs"}

    def mount(self, request, **kwargs):
        self.active_tab = "overview"

    def handle_params(self, params, uri):
        tab = params.get("tab", "overview")
        if tab in self.VALID_TABS:
            self.active_tab = tab
        self._load_tab_data()

Why it works:

  • dj-patch updates the URL immediately on the client (no round-trip delay for the URL change)
  • handle_params is a first-class lifecycle method with proper re-render sequencing
  • Browser back/forward and bookmarks work automatically
  • Idempotent -- calling handle_params twice with the same params is a no-op
  • System check djust.T010 detects the anti-pattern and suggests dj-patch

Rule of thumb:

DirectiveUse for
dj-clickActions that modify state (increment counter, delete item, toggle)
dj-patchNavigation that should update the URL (tabs, filters, pagination)
dj-navigateSPA navigation to a different LiveView over the WebSocket (full reload only as a fallback when the route isn't in the map)

URL Design Best Practices

  • Use query params for filter/sort/page state that should be shareable and bookmarkable.
  • Use replace=True for transient state changes (e.g., intermediate typing) to avoid polluting browser history.
  • Always implement handle_params() to restore state from URL -- this ensures deep links and browser back/forward work correctly.
  • Keep URL params flat and simple: ?category=books&page=2 rather than nested structures.