Htmx and URL State Management

Bookmarkable by Design: URL-Driven State in HTMX
Forget complex state libraries. Use the URL as your single source of truth for filters, sorting, and pagination in HTMX applications
Bookmarkable by Design: URL-Driven State in HTMX
When you move from React to HTMX, you trade complex state management for server-side simplicity. But you still need to handle filters, sorting, pagination, and search. Where does that state live now?
The answer is surprisingly elegant: in the URL itself. By treating URL parameters as your single source of truth, you get bookmarkable, shareable application state without needing to install another dependency.
The Pattern in Action
A URL like /?status=active&sortField=price&sortDir=desc&page=2
tells you everything about the current view. It’s not just an address—it’s a complete state representation that users can bookmark, share, or refresh without losing context.
Quick Start: Three Essential Steps
The entire pattern revolves around three synchronized steps:
- Server reads URL parameters and renders the appropriate view
- Client preserves all state when making HTMX requests
- Browser URL updates without page reload after each interaction
Let’s build this step by step.
Step 1: Server Reads URL State
Your server endpoint reads query parameters and uses them to render the initial view:
@Get("/")
@Render("data-table.eta")
async homepage(
@Query("sortField") sortField: string,
@Query("sortDir") sortDir: "asc" | "desc",
@Query("status") status: string,
@Query("page") page: string,
) {
// Parse with defaults
const pageNum = parseInt(page) || 1;
// Apply state to data query
const result = await this.dataService.getItems({
sortField: sortField || "name",
sortDir: sortDir || "asc",
status: status || "",
page: pageNum,
});
// Return both data and state for template
return {
items: result.items,
totalItems: result.total,
sortField: sortField || "name",
sortDir: sortDir || "asc",
status: status || "",
page: pageNum,
};
}
The template embeds this state directly in the DOM (using ETA templates in this case):
<div id="data-table"
data-status="<%= it.status %>"
data-sortfield="<%= it.sortField %>"
data-sortdir="<%= it.sortDir %>"
data-page="<%= it.page %>">
<select hx-get="/api/data"
hx-target="#data-table"
hx-vals="js:{...createPayload({status: this.value, page: 1})}">
<option value="" <%= it.status === '' ? 'selected' : '' %>>Alloption>
<option value="active" <%= it.status === 'active' ? 'selected' : '' %>>Activeoption>
select>
<th class="sortable <%= it.sortField === 'price' ? 'sorted' : '' %>"
hx-get="/api/data"
hx-target="#data-table"
hx-vals="js:{...createPayload({sortField: 'price'})}">
Price
<% if (it.sortField === 'price') { %>
<%= it.sortDir === 'asc' ? '↑' : '↓' %>
<% } %>
th>
div>
Key insights:
- State flows from URL → Server → DOM
- The
data-*
attributes make current state accessible to JavaScript - Server-side rendering means the page works before any JavaScript loads
Step 2: Client Coordination with createPayload()
The magic happens in createPayload()
, which coordinates state across user interactions:
function createPayload(newState = {}) {
// Get current state from URL and DOM
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
const table = document.querySelector("#data-table");
// Build current state from both sources
const currentState = {
sortField: params.get("sortField") || table.dataset.sortfield || "name",
sortDir: params.get("sortDir") || table.dataset.sortdir || "asc",
status: params.get("status") || table.dataset.status || "",
page: parseInt(params.get("page")) || parseInt(table.dataset.page) || 1,
};
// Handle sort direction toggling
let sortDir = currentState.sortDir;
if (newState.sortField && newState.sortField === currentState.sortField) {
// Same field: toggle direction
sortDir = currentState.sortDir === "asc" ? "desc" : "asc";
} else if (newState.sortField) {
// New field: use smart defaults
sortDir = ["price", "date"].includes(newState.sortField) ? "desc" : "asc";
}
// Merge states
const payload = {
...currentState,
...newState,
sortDir: sortDir,
};
// Update URL immediately
updateURLParams(payload);
return payload;
}
This function is deceptively simple but handles complex interactions:
- Preserves existing filters when sorting
- Resets to page 1 when filters change
- Toggles sort direction intelligently
- Updates the URL before HTMX makes its request
Step 3: Syncing the Browser URL
After each interaction, we update the URL without a page reload:
function updateURLParams(payload) {
const url = new URL(window.location.href);
const params = new URLSearchParams();
// Only include non-empty values
Object.entries(payload).forEach(([key, value]) => {
if (value && String(value).trim()) {
params.set(key, String(value));
}
});
// Update URL and create history entry for back button navigation
url.search = params.toString();
window.history.pushState({}, "", url.toString());
}
Using pushState
creates a history entry for each state change, allowing users to navigate through their filter and sort history with the browser’s back and forward buttons.
Preserving State Across Navigation
What happens when users navigate away and come back? We use localStorage to preserve their context:
// Save state when navigating to detail pages
document.addEventListener("click", (e) => {
if (e.target.matches("td a")) {
const params = window.location.search;
if (params) {
localStorage.setItem("tableParams", params);
localStorage.setItem("tableParamsExpiry", Date.now() + 1800000); // 30 min
}
}
});
// Restore state on breadcrumb links
document.addEventListener("DOMContentLoaded", () => {
const saved = localStorage.getItem("tableParams");
const expiry = localStorage.getItem("tableParamsExpiry");
if (saved && expiry && Date.now() < expiry) {
document.querySelectorAll("a[data-restore-params]").forEach(link => {
link.href = link.getAttribute("href") + saved;
});
} else {
// Clean up expired state
localStorage.removeItem("tableParams");
localStorage.removeItem("tableParamsExpiry");
}
});
Now a “Back to Listings” breadcrumb returns users to their exact filtered view.
Production Considerations
URL Length Limits: Browsers support URLs up to ~2000 characters. For complex filters, consider using abbreviated parameter names or moving some state server-side.
Parameter Validation: Always validate and sanitize URL parameters on the server. Treat them as untrusted user input.
State Expiration: The localStorage approach includes expiration to prevent stale state from persisting too long.
Testing: The pattern is highly testable since state is explicit:
// Test with mocked window.location
const result = createPayload({ page: 2 }, {
location: { href: "http://test.com?status=active" }
});
expect(result.status).toBe("active");
expect(result.page).toBe(2);
The Architecture Payoff
This URL-first approach delivers multiple benefits without the complexity of client-side state management libraries. Every view is inherently shareable, so you can send a colleague a link and they see will exactly what you see. The browser’s back button works as expected by returning to previous filter states. SEO is built-in since search engines can crawl every state combination. And the debugging experience is transparent because the current state is always visible in the address bar.
By embracing the URL as your state store, you’re not working around the web platform, you’re working with it. This pattern scales from simple sorting to complex multi-filter interfaces while maintaining the simplicity that drew you to HTMX in the first place.
The next time you reach for a state management library, consider whether the humble URL might be all you need. In many cases, it’s not just sufficient, it’s superior.
What's Your Reaction?






