Guides

HTMX patterns

Saaslivery UIs are server-rendered. HTMX loads HTML fragments, Alpine.js holds client-side state, and all data mutations go through the JSON API. This guide covers the patterns you will use constantly, and the two you must never use.

The contract

Tool Role Carries
HTMX Load HTML fragments GET requests returning HTML
Alpine.js Client-side state and reactivity UI state, form binding
MicroAPI All data operations POST/PUT/PATCH/DELETE returning JSON

Two hard rules fall out of this:

  1. HTMX never mutates data. No hx-post, hx-put, or hx-delete for create, update, or delete. Mutations go through MicroAPI to the API subdomain, where the business logic and validation live.
  2. No browser dialogs. Never hx-confirm, confirm(), or alert(). Confirmation is an inline UI state.

MicroAPI is loaded by the platform base template and wraps fetch with the API base URL, credentials, the X-Api-Version header, and error parsing:

const board = await MicroAPI.post('/boards', { name: 'Sprint 14' });
await MicroAPI.patch('/tasks/abc123', { title: 'Updated' });
await MicroAPI.del('/tasks/abc123');

Loading content on page load

<div hx-get="/app/tasks/partials/task-list"
     hx-trigger="load"
     hx-swap="innerHTML">
  Loading...
</div>

hx-trigger="load" fires when the element enters the DOM. The server returns an HTML partial that replaces the placeholder. Partial routes live under your app's /app/{slug}/partials/ prefix on the wildcard subdomain.

Click to load a panel

<button hx-get="/app/tasks/partials/task/{{ task.id }}"
        hx-target="#task-panel-root"
        hx-swap="innerHTML">
  View Task
</button>

<div id="task-panel-root"></div>

The server returns a Drawer partial that slides in from the right.

Creating data: Alpine + MicroAPI, then refresh via HTMX

The standard mutation flow. Alpine binds the form state, MicroAPI submits it, and on success the page re-fetches the affected partial:

<form x-data="{ title: '', error: '', async submit() {
        try {
          await MicroAPI.post('/tasks', { title: this.title });
          this.title = '';
          window.dispatchEvent(new CustomEvent('form:success', { detail: { action: '/tasks' } }));
        } catch (err) {
          this.error = (err.body && err.body.error) || 'Something went wrong';
        }
      } }"
      @submit.prevent="submit()">
  <div class="alert alert-danger small py-2" x-show="error" x-text="error" x-cloak></div>
  <input type="text" x-model="title" class="form-control" required>
  <button type="submit" class="btn btn-primary btn-sm">Create</button>
</form>
// The page listens for form:success and refreshes the relevant fragment
window.addEventListener('form:success', (e) => {
  if ((e.detail.action || '').includes('/tasks')) {
    htmx.ajax('GET', '/app/tasks/partials/task-list', {
      source: document.querySelector('#task-list'),
      target: '#task-list',
      swap: 'innerHTML',
    });
  }
});

htmx.ajax always needs a source

When calling htmx.ajax() programmatically, always pass a source element inside your app's screen. Without it, HTMX treats document.body as the requester: the button spinner never shows, and inside the shell the swap can land in another tab's identically-named target.

For anything beyond a one-field form, do not hand-roll this. The platform's form system renders a full drawer with validation, error display, and the form:success event wired up.

Tab switching without a reload

<div class="tabs">
  <button hx-get="/app/tasks/partials/board/{{ board.id }}/tab?tab=board"
          hx-target="#board-preview" hx-swap="innerHTML"
          class="tab active">Board</button>
  <button hx-get="/app/tasks/partials/board/{{ board.id }}/tab?tab=list"
          hx-target="#board-preview" hx-swap="innerHTML"
          class="tab">List</button>
</div>

<div id="board-preview"></div>

Each tab loads a different partial into the same target. Inside the workspace shell, history is managed by the shell; do not push history states of your own.

Infinite scroll / pagination

<div id="task-list">
  {% for task in tasks %}
  <div class="task-card">{{ task.title }}</div>
  {% endfor %}

  {% if has_more %}
  <div hx-get="/app/tasks/partials/tasks?page={{ next_page }}"
       hx-trigger="revealed"
       hx-swap="afterend"
       hx-target="this">
    Loading more...
  </div>
  {% endif %}
</div>

hx-trigger="revealed" fires when the element scrolls into view, loading the next page of results.

Search with debounce

<input type="search"
       name="q"
       placeholder="Search tasks..."
       hx-get="/app/tasks/partials/search-results"
       hx-trigger="input changed delay:300ms"
       hx-target="#search-results">

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

The 300ms debounce prevents flooding the server while the user types.

Delete with inline confirmation

Deletes go through MicroAPI, and the confirmation is an Alpine state, not a browser dialog:

<div x-data="{ confirming: false, async destroy() {
       await MicroAPI.del('/tasks/{{ task.id }}');
       window.dispatchEvent(new CustomEvent('form:success', { detail: { action: '/tasks' } }));
     } }">
  <button x-show="!confirming" @click="confirming = true"
          class="btn btn-sm btn-outline-danger">Delete</button>
  <span x-show="confirming" x-cloak class="d-inline-flex gap-1 align-items-center">
    <span class="small text-muted">Delete this task?</span>
    <button @click="destroy()" class="btn btn-sm btn-danger">Yes</button>
    <button @click="confirming = false" class="btn btn-sm btn-outline-secondary">No</button>
  </span>
</div>

Alpine components inside HTMX partials

Two rules keep injected fragments working:

  1. Use inline x-data="{...}", never a named function like x-data="myComponent()". The function will not exist when Alpine processes injected DOM.
  2. Add x-init="htmx.process($el)" so HTMX discovers hx-* attributes inside the new fragment.
<div x-data="{ editing: false }" x-init="htmx.process($el)">
  ...
</div>

And one syntax trap: the quote character delimiting an x-data attribute must never appear inside it. A double-quoted x-data may only contain single quotes inside, and vice versa. One stray delimiter silently truncates the component.

Pattern summary

Pattern Use HTMX Use MicroAPI
Load a list or panel hx-get + hx-trigger/hx-target
Switch tabs hx-get per tab
Search hx-get + debounce
Pagination hx-get + hx-trigger="revealed"
Create / update / delete MicroAPI.post/put/patch/del
Any form submission Alpine @submit.prevent + MicroAPI
Refresh after a mutation htmx.ajax() with a source triggered by form:success