HTMX History and URL

When HTMX updates parts of a page, the browser's URL bar does not change by default. This breaks two important user expectations: the Back button and shareable URLs. A user who navigates from a product list to a product detail should be able to press Back and return to the list, and they should be able to copy the URL and share it. HTMX gives you full control over URL management through a set of attributes and response headers.

The Problem Without URL Management

  User visits: example.com/products
  User clicks product #42 → HTMX loads detail into #main
  Browser URL stays:  example.com/products  ← wrong!
  User presses Back → goes to the previous site entirely
  User copies URL   → shares the product list, not the detail
  With HTMX history management:
  User visits: example.com/products
  User clicks product #42 → HTMX loads detail into #main
  Browser URL becomes:  example.com/products/42  ← correct
  User presses Back → returns to product list view
  User copies URL  → shares the exact product detail

hx-push-url: Push a New URL

Add hx-push-url="true" to push the request URL into the browser history when HTMX fires the request:

<a
  hx-get="/products/42"
  hx-target="#main"
  hx-push-url="true">
  View Product #42
</a>

When clicked, HTMX loads /products/42 into #main and pushes /products/42 into the browser history. The URL bar updates. The Back button works.

You can also set a specific URL instead of using the request URL:

<button
  hx-get="/products/42"
  hx-target="#main"
  hx-push-url="/products/42">
  View Product
</button>

hx-replace-url: Replace Without a History Entry

Use hx-replace-url when you want to update the URL but not add to history. This is useful for filter or sort actions where the Back button should not step through every filter the user tried:

<select
  hx-get="/products"
  hx-target="#product-list"
  hx-replace-url="/products?sort=price"
  hx-trigger="change">
  <option value="name">Sort by Name</option>
  <option value="price">Sort by Price</option>
</select>
  History stack comparison:

  hx-push-url:
  [Home] → [Products] → [Sort:Name] → [Sort:Price] → [Sort:Date]
  Back travels through every filter — annoying

  hx-replace-url:
  [Home] → [Products] → [Sort:Date]   (only the current filter is in history)
  Back skips directly to Products — clean

Serving Full Pages for Direct URL Visits

When a user visits /products/42 directly (by typing the URL or refreshing), the server must return a full HTML page — not just a fragment. When HTMX loads /products/42 via an HTMX request, the server returns only the fragment. Use the HX-Request header to differentiate:

# Flask
@app.route('/products/<int:id>')
def product_detail(id):
    product = Product.query.get(id)
    is_htmx = request.headers.get('HX-Request') == 'true'

    if is_htmx:
        return render_template('partials/product_detail.html', product=product)
    else:
        return render_template('product_detail_full.html', product=product)

History Restoration

When the user presses the Back button and HTMX detects a history entry it pushed, it automatically restores the page content. HTMX caches the HTML state of the page in sessionStorage every time it pushes a URL. When navigating back to that URL, it restores from cache instead of making a new server request.

  Page history cache stored in sessionStorage:

  Key: "/products"        → HTML snapshot of the products list page
  Key: "/products/42"     → HTML snapshot of product #42 detail page
  Key: "/products/43"     → HTML snapshot of product #43 detail page

If the cached version is stale (for example, inventory changed), the user sees outdated content briefly. To avoid this, control which elements get included in the cache snapshot using hx-history-elt:

<!-- Only cache the content of #main, not the whole page -->
<main id="main" hx-history-elt>
  ...page content...
</main>

Disabling History Caching for Sensitive Pages

For pages with sensitive data (account details, payment information), disable HTMX history caching entirely:

<!-- Disable caching for this page -->
<meta name="htmx-config" content='{"historyCacheSize": 0}'>

Or disable it globally in the HTMX configuration:

<script>
htmx.config.historyCacheSize = 0;
</script>

Server-Side URL Push With HX-Push-Url Header

The server can push a URL even when the triggering element does not have hx-push-url. Return the HX-Push-Url response header:

# Flask
@app.route('/search')
def search():
    q = request.args.get('q', '')
    results = Product.query.filter(Product.name.ilike(f'%{q}%')).all()

    response = Response(render_template('partials/results.html', results=results))
    response.headers['HX-Push-Url'] = f'/search?q={q}'
    return response

The search results load into the page, and the URL becomes /search?q=laptop — making the search result shareable.

Full URL-Aware Navigation Example

<nav>
  <a hx-get="/home"     hx-target="#content" hx-push-url="true">Home</a>
  <a hx-get="/about"    hx-target="#content" hx-push-url="true">About</a>
  <a hx-get="/products" hx-target="#content" hx-push-url="true">Products</a>
</nav>

<main id="content">
  <!-- Page content swaps here -->
</main>

This pattern turns the site into a single-page application where the nav items load content without a full reload, but the URL bar, Back button, and direct-link sharing all work correctly.

Key Takeaway

Use hx-push-url="true" to keep the browser URL in sync with the current HTMX content. Use hx-replace-url for filter or sort updates where you do not want history entries for every state. Serve full pages for direct URL visits by checking the HX-Request header on the server. Use hx-history-elt to control what HTMX caches for history restoration. Proper URL management makes your HTMX application feel like a polished web product with working navigation, bookmarks, and shareable links.

Leave a Comment

Your email address will not be published. Required fields are marked *