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:
- HTMX never mutates data. No
hx-post,hx-put, orhx-deletefor create, update, or delete. Mutations go throughMicroAPIto the API subdomain, where the business logic and validation live. - No browser dialogs. Never
hx-confirm,confirm(), oralert(). 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:
- Use inline
x-data="{...}", never a named function likex-data="myComponent()". The function will not exist when Alpine processes injected DOM. - Add
x-init="htmx.process($el)"so HTMX discovershx-*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 |