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' : '' % >>All option > < option value = "active" <%= it.status = == 'active' ? 'selected' : '' % >>Active option > 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
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.