Insights Business| SaaS| Technology Building Modern UIs with HTMX—Essential Implementation Patterns
Business
|
SaaS
|
Technology
Feb 14, 2026

Building Modern UIs with HTMX—Essential Implementation Patterns

AUTHOR

James A. Wondrasek James A. Wondrasek
Graphic representation of the topic The HTMX Renaissance: Is the React Era Ending?

The web development world is shifting. Heavyweight JavaScript frameworks are losing ground to simpler, HTML-centric architectures. HTMX is at the centre of that shift.

HTMX lets you build dynamic, interactive UIs using HTML attributes like hx-get, hx-post, hx-swap, and hx-target instead of writing complex client-side JavaScript. If you want the theory and reasoning behind this approach, the HTMX architectural principles are covered in our renaissance guide.

This article is practical. It gives you 9 production-tested implementation patterns. If you’re migrating from React to HTMX, these patterns replace common React implementations. Each pattern includes a problem statement, an HTMX solution, and working code examples using popular backend frameworks. Patterns progress from basic interactive UI needs through real-time features and routing to performance optimisation and debugging. Every pattern integrates accessibility and security from the start.

How Do You Implement Dependent Dropdowns with HTMX?

Dependent dropdowns are everywhere. You select a country, and a list of states appears. You select a state, and a list of cities appears. Simple in concept. But it requires server coordination.

HTMX uses hx-get triggered on the change event to fetch dependent dropdown options from the server. When you select a country, HTMX fires a GET request to a server endpoint that returns the relevant state <option> elements as an HTML fragment. The hx-target attribute points at the dependent <select> element, and hx-swap="innerHTML" replaces its options with the server response.

No client-side JavaScript state management. The server owns the data relationships and returns ready-to-render HTML.

Here’s the HTML:

<select name="country" id="country"
        hx-get="/api/states/"
        hx-trigger="change"
        hx-target="#state-select"
        hx-swap="innerHTML"
        hx-include="[name='country']">
  <option value="">Select a country</option>
  <option value="us">United States</option>
  <option value="au">Australia</option>
</select>

<select name="state" id="state-select"
        hx-get="/api/cities/"
        hx-trigger="change"
        hx-target="#city-select"
        hx-swap="innerHTML"
        aria-live="polite">
  <option value="">Select a state</option>
</select>

<select name="city" id="city-select" aria-live="polite">
  <option value="">Select a city</option>
</select>

The Django view returns filtered <option> elements:

def get_states(request):
    country = request.GET.get('country')
    states = State.objects.filter(country=country)
    html = '<option value="">Select a state</option>'
    for state in states:
        html += f'<option value="{state.code}">{state.name}</option>'
    return HttpResponse(html)

Use hx-indicator for a loading spinner while options load. The aria-busy and aria-live="polite" attributes announce updates to screen readers. The hx-include attribute ensures the selected value is sent with the request.

The form still works without JavaScript. That’s progressive enhancement built in.

How Do You Handle Complex Form Validation with HTMX?

HTMX enables real-time server-side validation. You send field values to the server on blur or input events and swap error messages into the DOM without a full page reload.

Use hx-post on individual form fields with hx-trigger="blur changed" to validate as user moves through form. The server validates the submitted value, returns an HTML fragment containing either a success indicator or an error message, and HTMX swaps it into the target element adjacent to the field.

Here’s an email validation example:

<input type="email"
       name="email"
       hx-post="/validate/email/"
       hx-trigger="blur changed"
       hx-target="next .error"
       hx-swap="outerHTML"
       aria-describedby="email-error">
<span class="error" id="email-error"></span>

The Django endpoint validates and returns the response:

def validate_email(request):
    email = request.POST.get('email', '')
    if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
        return HttpResponse(
            '<span class="error" role="alert">Invalid email format</span>'
        )
    return HttpResponse('<span class="success">✓</span>')

The aria-describedby attribute links inputs to error messages. The aria-invalid="true" attribute marks errored fields. The role="alert" attribute announces errors to screen readers immediately.

A common pitfall is forgetting to include CSRF tokens in HTMX POST requests. Some frameworks require workarounds when template engines escape JSON in HTML attributes. Use hx-headers with an unescaped token or set up a global event listener for htmx:configRequest.

How Do You Implement Real-Time Updates Using HTMX WebSockets and SSE?

HTMX provides hx-ws for bidirectional WebSocket connections and hx-sse for unidirectional Server-Sent Events.

Use WebSockets when you need bidirectional communication—collaborative editing, chat, interactive dashboards. Use Server-Sent Events when the server pushes updates to the client—live notifications, stock tickers, or activity streams.

In both cases, the server sends HTML fragments that HTMX swaps into the DOM. SSE is simpler to implement and more reliable over HTTP/2. WebSockets provide lower latency for truly interactive scenarios.

Here’s a WebSocket example for chat:

<div hx-ext="ws" ws-connect="/ws/chat/">
  <div id="chat-messages" aria-live="polite"></div>
  <form ws-send>
    <input type="text" name="message" placeholder="Type a message..." />
    <button type="submit">Send</button>
  </form>
</div>

The Django Channels endpoint returns HTML fragments:

class ChatConsumer(AsyncWebsocketConsumer):
    async def receive(self, text_data):
        data = json.loads(text_data)
        html = f'<div><strong>{user}:</strong> {data["message"]}</div>'
        await self.channel_layer.group_send(
            self.room_group_name,
            {'type': 'chat_message', 'html': html}
        )

Here’s a Server-Sent Events example for notifications:

<div hx-ext="sse" sse-connect="/events/notifications/" sse-swap="message">
  <ul id="notification-list" aria-live="polite">
    <!-- Notifications appear here -->
  </ul>
</div>

The Express.js SSE endpoint yields HTML fragments:

app.get('/events/notifications/', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');

  const sendNotification = (notification) => {
    const html = `<li><strong>${notification.title}</strong></li>`;
    res.write(`event: message\ndata: ${html}\n\n`);
  };

  notificationService.on('new', sendNotification);
  req.on('close', () => notificationService.off('new', sendNotification));
});

The aria-live="polite" attribute on real-time update containers announces changes to screen readers without interrupting the user’s current task.

For a detailed analysis of performance implications of these real-time patterns, check out our benchmark comparison.

How Do You Structure Routing in an HTMX Application for SPA-Like Navigation?

HTMX achieves SPA-like navigation using hx-boost, hx-push-url, and hx-target to swap page content.

hx-boost="true" converts standard anchor tags into AJAX requests that swap the response into a target element. hx-push-url="true" ensures the browser address bar updates and history entries are created, so back and forward buttons work correctly.

The server returns full HTML pages when accessed directly but returns partial HTML fragments when it detects an HTMX request via the HX-Request header.

Here’s the layout:

<nav hx-boost="true" hx-target="#main-content" hx-push-url="true">
  <a href="/">Dashboard</a>
  <a href="/reports/">Reports</a>
  <a href="/settings/">Settings</a>
</nav>

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

The Django view detects HTMX requests:

def dashboard(request):
    if request.headers.get('HX-Request'):
        return render(request, 'dashboard_content.html')
    else:
        return render(request, 'dashboard_full.html')

Replacing interactive elements without preserving their state is a common gotcha. Use hx-preserve on elements that should maintain their state across swaps.

How Do You Implement Progressive Enhancement with HTMX?

Progressive enhancement means building a baseline experience that works without JavaScript, then layering HTMX on top.

Build working HTML forms with standard action and method attributes first, then add HTMX attributes to upgrade to AJAX. When JavaScript is available, HTMX intercepts the form submission. When it’s not, the browser’s native form handling takes over.

Here’s a search form that works with or without JavaScript:

<form action="/search/" method="GET"
      hx-get="/search/"
      hx-target="#results"
      hx-push-url="true">
  <input type="text" name="q" required />
  <button type="submit">Search</button>
</form>

<div id="results"></div>

The server handles both types of requests:

def search(request):
    query = request.GET.get('q', '')
    results = Product.objects.filter(name__icontains=query)
    context = {'results': results, 'query': query}

    if request.headers.get('HX-Request'):
        return render(request, 'search_results.html', context)
    else:
        return render(request, 'search_page.html', context)

The hx-boost shortcut automatically progressively enhances all links and forms in a container. Loading indicators degrade gracefully—the .htmx-indicator class is hidden by default, and HTMX shows them during requests.

This strategy naturally improves accessibility, SEO, and resilience. For foundational HTMX concepts on hypermedia architecture, check out The HTMX Renaissance.

How Do You Update Multiple Page Sections from a Single HTMX Request?

Out-of-band (OOB) swaps let single server response update multiple unrelated DOM elements simultaneously. Adding an item to a shopping cart updates both the cart contents and the header cart count.

The server includes additional elements in its response with hx-swap-oob="true" and matching id attributes. HTMX automatically finds and replaces those elements in the existing page.

Here’s an “add to cart” button:

<button hx-post="/cart/add/{{ product.id }}/"
        hx-target="#cart-items"
        hx-swap="innerHTML">Add to Cart</button>

<div id="cart-count">Cart: 0 items</div>
<div id="cart-items"></div>

The Django view returns the cart plus an out-of-band element:

def add_to_cart(request, product_id):
    cart = request.session.get('cart', {})
    cart[product_id] = cart.get(product_id, 0) + 1
    request.session['cart'] = cart

    cart_html = render_to_string('cart_items.html', {'cart': cart})
    total_items = sum(cart.values())
    count_html = f'<div id="cart-count" hx-swap-oob="true">Cart: {total_items} items</div>'

    return HttpResponse(cart_html + count_html)

The hx-swap-oob="true" uses the default outerHTML strategy. Use hx-swap-oob="innerHTML" to replace only contents, or hx-swap-oob="beforeend" to append.

A common pitfall is that OOB elements must have id attributes that match existing elements on the page. If the id doesn’t exist, HTMX silently ignores the OOB swap.

Add aria-live regions on elements likely to receive OOB updates:

<div id="cart-count" aria-live="polite" aria-atomic="true">
  Cart: <span class="count">0</span> items
</div>

The aria-atomic="true" attribute ensures the entire region is announced, not just the changed text.

When Should You Add Alpine.js to Complement HTMX?

HTMX handles server interactions. But some UI interactions require instant client-side state changes without a server round-trip. Toggling a dropdown, managing modal visibility, or switching tabs all happen entirely in the browser.

Alpine.js fills this gap. At 15KB, it complements HTMX’s server-side approach without framework complexity. The rule of thumb: if the interaction needs new data from the server, use HTMX. If it doesn’t, use Alpine.js.

Together, HTMX and Alpine.js cover full spectrum of interactive UI needs with combined payload under 30KB—compared to React’s 42KB minimum.

Here’s a modal pattern:

<div x-data="{ open: false }">
  <button @click="open = true">Open Modal</button>

  <div x-show="open"
       @click.away="open = false"
       role="dialog">
    <div hx-get="/modal/content/"
         hx-trigger="revealed once"
         hx-swap="innerHTML">Loading...</div>
    <button @click="open = false">Close</button>
  </div>
</div>

Alpine.js manages visibility. HTMX loads content when first revealed.

Here’s a tabbed interface:

<div x-data="{ activeTab: 'profile' }">
  <button @click="activeTab = 'profile'">Profile</button>
  <button @click="activeTab = 'settings'">Settings</button>

  <div x-show="activeTab === 'profile'"
       hx-get="/tabs/profile/"
       hx-trigger="revealed once">Loading...</div>

  <div x-show="activeTab === 'settings'"
       hx-get="/tabs/settings/"
       hx-trigger="revealed once">Loading...</div>
</div>

Do you always need Alpine.js? No. Many HTMX applications work perfectly without it. But complex UIs benefit from having a client-side state management tool that stays out of the way.

What Are the Essential HTMX Performance Optimisation Patterns?

HTMX applications benefit from three key performance techniques: request debouncing, loading indicators, and HTTP caching.

Request debouncing uses hx-trigger modifiers. hx-trigger="keyup changed delay:500ms" waits 500 milliseconds after you stop typing before sending the request. This reduces server load on search-as-you-type inputs.

Here’s a debounced search input:

<input type="text"
       name="search"
       hx-get="/search/"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#results"
       hx-indicator="#spinner"
       placeholder="Search products...">

<div id="spinner" class="htmx-indicator">Searching...</div>

<div id="results" aria-live="polite" aria-atomic="false">
  <!-- Results appear here -->
</div>

Loading indicators using hx-indicator display a spinner or progress bar during requests, improving perceived performance.

HTTP caching works naturally with HTMX because responses are standard HTML. Set Cache-Control headers on server responses and browsers and CDNs cache HTML fragments just like any other resource:

from django.views.decorators.cache import cache_control

@cache_control(public=True, max_age=300)
def product_list(request):
    products = Product.objects.filter(featured=True)
    return render(request, 'product_list.html', {'products': products})

Lazy loading with hx-trigger="revealed" defers loading of below-the-fold content until you scroll to it:

<div hx-get="/api/recommendations/"
     hx-trigger="revealed"
     hx-swap="innerHTML">
  <p>Loading recommendations...</p>
</div>

Request deduplication with hx-trigger modifiers prevents duplicate in-flight requests:

<button hx-post="/api/submit/"
        hx-trigger="click throttle:1s"
        hx-target="#result">
  Submit
</button>

The throttle:1s modifier ensures the button can only trigger one request per second, even if clicked multiple times.

For detailed performance benchmarks comparing HTMX and React, check out our architecture deep dive.

How Do You Debug HTMX Applications Effectively?

Debugging HTMX applications centres on browser DevTools, HTMX’s built-in event system, and server-side debugging.

Enable HTMX’s debug logging with htmx.logAll() in the browser console to see every event HTMX fires. Use the DevTools Network tab to inspect HTMX requests—look for the HX-Request: true header, check response content is valid HTML fragments, and verify correct Content-Type headers.

Here’s how to set up comprehensive logging:

// Add to your main JavaScript file or run in console
htmx.logAll();

// Or listen for specific events
document.addEventListener('htmx:configRequest', (evt) => {
  console.log('Request config:', evt.detail);
});

document.addEventListener('htmx:afterSwap', (evt) => {
  console.log('After swap:', evt.detail);
});

document.addEventListener('htmx:responseError', (evt) => {
  console.error('Response error:', evt.detail);
  // Could show user-friendly error message here
});

The htmx:beforeRequest event is useful for request intercepting and logging:

document.addEventListener('htmx:beforeRequest', (evt) => {
  console.log('About to send request to:', evt.detail.xhr.url);
  console.log('Request parameters:', evt.detail.requestConfig);

  // Could add authentication headers here
  evt.detail.xhr.setRequestHeader('X-Custom-Header', 'value');
});

Common pitfalls and solutions:

Content appears in wrong place: Incorrect hx-swap strategy. Use innerHTML to replace contents, outerHTML to replace the entire element, beforeend to append, or afterbegin to prepend.

Silent failures: Missing target elements. HTMX silently fails if the hx-target selector doesn’t match any elements. Check the selector in DevTools.

JSON instead of HTML: Endpoint returns JSON when HTMX expects HTML fragments. Ensure your endpoints return HTML, not JSON.

Content Security Policy blocking: CSP can block HTMX if you use hx-on attributes (which execute JavaScript). Prefer hx-trigger with server-side logic over hx-on for CSP-compatible implementations.

Here’s a troubleshooting checklist:

  1. Open DevTools Network tab and filter for requests with HX-Request: true header
  2. Check response status code—200s are good, 403s are usually CSRF, 500s are server errors
  3. Inspect response content—should be HTML fragments, not JSON or error pages
  4. Verify Content-Type: text/html header in response
  5. Check hx-target selector matches an element in the DOM
  6. Verify hx-swap strategy is appropriate for the target
  7. Look for JavaScript errors in the Console tab
  8. Run htmx.logAll() and retry the action to see lifecycle events

The debugging experience is better than React. In HTMX, the source and browser page are a very close match because you’re using simpler markup. In React, you have to mentally map markup to your source, especially when using component frameworks.

FAQ Section

What backend frameworks work best with HTMX?

HTMX works with any backend that can return HTML. Django, Spring Boot, Ruby on Rails, and Laravel have the strongest template engine support—Jinja2, Thymeleaf, ERB, and Blade respectively. Express.js and Go/Gin also work well. Choose the framework your team already knows. HTMX doesn’t care.

How do you handle CSRF protection with HTMX POST requests?

Include the CSRF token in HTMX request headers using hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' on the form or a parent element. Alternatively, configure HTMX globally with htmx.config.getCsrfToken or use your framework’s middleware to read the token from cookies. Django’s {% csrf_token %} template tag works within HTMX-enhanced forms.

Can HTMX handle file uploads with progress indicators?

Yes. Use hx-post on a file upload form with hx-encoding="multipart/form-data". For progress indication, use the htmx:xhr:progress event to update a progress bar element. The server processes the file and returns an HTML fragment confirming the upload.

How do you implement infinite scroll with HTMX?

Add hx-get="/items/?page=2" hx-trigger="revealed" hx-swap="afterend" to the last item in a list. When you scroll to it, HTMX loads the next page and appends it. The server includes updated pagination attributes on the new last element to continue the pattern.

Does HTMX work with Content Security Policy headers?

HTMX attributes are HTML attributes, not inline JavaScript, so they work under strict CSP without unsafe-inline. However, if you use hx-on attributes (which execute JavaScript), you need either unsafe-inline or a nonce-based CSP. Stick with hx-trigger and server-side logic over hx-on for CSP-compatible implementations.

How do you test HTMX applications?

Focus on integration tests that test the full request-response cycle. Use your backend framework’s test client to POST to HTMX endpoints and assert the returned HTML fragments contain expected content. For end-to-end testing, Playwright and Cypress can interact with HTMX-enhanced elements normally. Unit testing individual HTMX attributes isn’t necessary.

What is the difference between hx-swap=”innerHTML” and hx-swap=”outerHTML”?

innerHTML replaces the contents of the target element, keeping the target itself intact. outerHTML replaces the entire target element including its tag. Use innerHTML when updating content within a container (search results inside a <div>). Use outerHTML when replacing the element itself (swapping an edit button with a save button).

How do you handle error states and empty responses in HTMX?

Listen for htmx:responseError events to catch HTTP errors and display user-friendly messages. For empty responses (204 No Content), HTMX performs no swap by default. Use htmx:beforeSwap to intercept responses and customise behaviour based on status codes—for example, showing an error notification on 422 validation errors.

Can you use HTMX with existing React components on the same page?

Yes. HTMX and React can coexist on the same page. HTMX manages its target elements while React manages its root container. The key constraint is that HTMX should not swap content inside React’s mount point, and React should not manipulate elements that HTMX targets. This pattern supports incremental migration.

How do you manage browser history and deep linking with HTMX?

Use hx-push-url="true" on requests that represent navigation to update the browser URL and create history entries. HTMX automatically handles the popstate event to restore previous content when users click back. For deep linking, ensure server endpoints return full pages for direct URL access and fragments for HTMX requests.

What accessibility challenges does HTMX introduce and how do you solve them?

The primary challenges are focus management after DOM swaps and screen reader announcements for dynamic content. Use aria-live="polite" regions for content that updates dynamically. After swaps that change layout, programmatically set focus with htmx:afterSwap event listeners. Follow the W3C ARIA Authoring Practices Guide for interactive patterns like modals and tabs.

How large is HTMX compared to React and does it affect page performance?

HTMX is approximately 14KB minified and gzipped, compared to React’s 42KB minimum (React + ReactDOM) before any application code. HTMX applications typically achieve faster First Contentful Paint and Time to Interactive because they require no JavaScript bundle parsing or client-side rendering. Server-rendered HTML is immediately visible to users.

Conclusion

These nine implementation patterns demonstrate that HTMX handles real-world UI complexity without JavaScript framework overhead. From dependent dropdowns and complex form validation through real-time WebSocket communication and SPA-like routing, HTMX delivers modern interactive experiences using HTML attributes and server-side logic.

The patterns share common characteristics: progressive enhancement ensures baseline functionality without JavaScript, server-side rendering eliminates client-side state complexity, and accessibility integrates naturally through semantic HTML and ARIA attributes. Combined with optional Alpine.js for pure client-side interactions, HTMX provides a complete toolkit for modern web application development.

For the architectural foundations and philosophical context behind these patterns, see The HTMX Renaissance—Rethinking Web Architecture for 2026. The hypermedia-driven approach these patterns embody represents a fundamental rethinking of how we build for the web.

AUTHOR

James A. Wondrasek James A. Wondrasek

SHARE ARTICLE

Share
Copy Link

Related Articles

Need a reliable team to help achieve your software goals?

Drop us a line! We'd love to discuss your project.

Offices
Sydney

SYDNEY

55 Pyrmont Bridge Road
Pyrmont, NSW, 2009
Australia

55 Pyrmont Bridge Road, Pyrmont, NSW, 2009, Australia

+61 2-8123-0997

Jakarta

JAKARTA

Plaza Indonesia, 5th Level Unit
E021AB
Jl. M.H. Thamrin Kav. 28-30
Jakarta 10350
Indonesia

Plaza Indonesia, 5th Level Unit E021AB, Jl. M.H. Thamrin Kav. 28-30, Jakarta 10350, Indonesia

+62 858-6514-9577

Bandung

BANDUNG

Jl. Banda No. 30
Bandung 40115
Indonesia

Jl. Banda No. 30, Bandung 40115, Indonesia

+62 858-6514-9577

Yogyakarta

YOGYAKARTA

Unit A & B
Jl. Prof. Herman Yohanes No.1125, Terban, Gondokusuman, Yogyakarta,
Daerah Istimewa Yogyakarta 55223
Indonesia

Unit A & B Jl. Prof. Herman Yohanes No.1125, Yogyakarta, Daerah Istimewa Yogyakarta 55223, Indonesia

+62 274-4539660