hx-push-url and the Death of Client-Side Routing
BudgetFlow's navigation starts with ordinary server routes. A direct visit to a URL returns a full page; clicking a link inside the app replaces the main content without a full reload. htmx handles that second request.
The URL still matters. People can bookmark a budget, refresh it, or open it in another tab. The server handles each of those requests using the same route.
The link uses hx-push-url="true" to add the destination to browser history. Two other attributes tell htmx what to request and where to put the response.
Why We Kept Routes on the Server
A client router is useful when the browser owns the page state. In BudgetFlow, Flask already resolves URLs, loads data, checks access, and renders templates. Duplicating those routes in the browser would give us another place to keep in sync.
For example, /budgets/5/view identifies a budget page on the server. The browser does not need a separate route definition or a client copy of the budget data just to navigate there.
Server-rendered HTML also appears as soon as the response arrives. We do not hydrate a second copy of the page before its links work.
This fits an app whose screens are mostly server-rendered forms and tables. Apps with substantial client state may make a different choice.
The Link
A budget list item uses three htmx attributes:
<ui-button clear
hx-get="{{ url_for('budgets.view', budget_id=budget.id) }}"
hx-push-url="true"
hx-target="main"
>
<span>{{ budget.name }}</span>
</ui-button>
The route is still defined in Flask. These attributes control the in-page navigation:
hx-getfetches the new page content from the server.hx-push-url="true"updates the browser's URL bar and pushes a history entry.hx-target="main"swaps the response into the<main>element.
When the user clicks, htmx requests the budget page and swaps the response into <main>. The address bar changes to the URL in hx-get.
One Route, Different Response Shapes
A direct visit to /budgets/5/view needs the full layout. An htmx navigation request needs only the content to swap into <main>. In simplified form, the render helper checks the request type:
def render_template_htmx_aware(template_name, **context):
if g.is_htmx_partial_request:
return render_template(template_name, **context)
if g.is_htmx_history_restore_request:
history_template = (
"<main hx-history-elt>"
f'{{% include "{template_name}" %}}'
"</main>"
)
return render_template_string(history_template, **context)
full_template = (
'{% extends "base.html" %}'
"{% block content %}"
f'{{% include "{template_name}" %}}'
"{% endblock %}"
)
return render_template_string(full_template, **context)
A normal visit gets the base layout. A history restore gets a <main> wrapper. The same route supplies the data in each case:
@bp.route("/budgets/<int:budget_id>/view")
@login_required
def view(budget_id: int) -> str:
db_budget = guard_user_must_own(Budget, budget_id, by_relation=Budget.user)
data_budget = DataBudget.from_db_budget(db_budget).flatten()
flows = sorted(
data_budget.flows,
key=lambda flow: flow.get_amount(db_budget.interval),
reverse=True,
)
return render_template_htmx_aware(
ViewTemplate(budget_id=budget_id, flows=flows)
)
That is why a refresh or copied link works: Flask can render the destination without first loading an htmx page.
Back and Forward
htmx manages the history entries added by hx-push-url. On back or forward, htmx requests the URL again and replaces the element marked hx-history-elt. BudgetFlow marks <main> for that purpose, and the render helper has a response shape for history requests.
It is worth testing back and forward after a form submission as well as after a simple link click. The new request should show the current server state.
Not Everything Is Navigation
BudgetFlow also fetches modals with htmx. Opening one does not change the page URL:
<ui-button
hx-get="..."
hx-target="body"
hx-swap="beforeend"
>
Open Settings
</ui-button>
Here, hx-target="body" and hx-swap="beforeend" append the returned modal to the document. Closing it removes that element.
We use hx-push-url for destinations people may want to revisit. A temporary overlay leaves history alone.
What You Give Up
Each navigation waits for a server response. On a slow connection, the next screen appears slowly unless you show a loading state.
Animated transitions also need extra work. The default swap replaces the content when the response arrives.
The navigation depends on the network, so offline use would need a separate caching strategy. Those limits matter if the product needs offline screens or immediate local transitions.
For BudgetFlow's forms and account pages, server-rendered navigation has been a good fit. The important requirement is that a direct request and an htmx request both render the right content.
What This Keeps Simple
Route definitions and authorization stay in Flask. There is no separate client route table to update when a URL changes.
A fresh page load works through the same Flask route. This makes copied links and bookmarks straightforward to support.
The server returns the HTML the user sees, with no hydration step. If that matches your application's needs, a few htmx attributes can cover navigation without a browser-side router.