14 KiB
HTMX Integration Patterns - Public Web Frontend
Last Updated: November 18, 2025
Overview
This document outlines HTMX usage patterns, best practices, and common implementations for the Code of Conquest web frontend.
HTMX Benefits:
- Server-side rendering with dynamic interactions
- No JavaScript framework overhead
- Progressive enhancement (works without JS)
- Clean separation of concerns
- Natural integration with Flask/Jinja2
Core HTMX Attributes
hx-get / hx-post / hx-put / hx-delete
Make HTTP requests from HTML elements:
<!-- GET request -->
<button hx-get="/api/v1/characters" hx-target="#characters">
Load Characters
</button>
<!-- POST request -->
<form hx-post="/api/v1/characters" hx-target="#result">
<!-- form fields -->
<button type="submit">Create</button>
</form>
<!-- DELETE request -->
<button hx-delete="/api/v1/characters/{{ character_id }}"
hx-target="closest .character-card"
hx-swap="outerHTML">
Delete
</button>
hx-target
Specify where to insert the response:
<!-- CSS selector -->
<button hx-get="/content" hx-target="#content-div">Load</button>
<!-- Relative selectors -->
<button hx-delete="/item" hx-target="closest .item">Delete</button>
<button hx-post="/append" hx-target="next .list">Add</button>
<!-- Special values -->
<button hx-get="/modal" hx-target="body">Show Modal</button>
hx-swap
Control how content is swapped:
<!-- innerHTML (default) -->
<div hx-get="/content" hx-target="#div" hx-swap="innerHTML"></div>
<!-- outerHTML (replace element itself) -->
<div hx-get="/content" hx-target="#div" hx-swap="outerHTML"></div>
<!-- beforebegin (before target) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforebegin"></div>
<!-- afterbegin (first child) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterbegin"></div>
<!-- beforeend (last child) -->
<div hx-get="/content" hx-target="#div" hx-swap="beforeend"></div>
<!-- afterend (after target) -->
<div hx-get="/content" hx-target="#div" hx-swap="afterend"></div>
<!-- none (no swap, just trigger) -->
<div hx-get="/trigger" hx-swap="none"></div>
hx-trigger
Specify what triggers the request:
<!-- Click (default for buttons) -->
<button hx-get="/content">Click Me</button>
<!-- Change (default for inputs) -->
<select hx-get="/filter" hx-trigger="change">...</select>
<!-- Custom events -->
<div hx-get="/content" hx-trigger="customEvent">...</div>
<!-- Multiple triggers -->
<div hx-get="/content" hx-trigger="mouseenter, focus">...</div>
<!-- Trigger modifiers -->
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results">
<!-- Polling -->
<div hx-get="/status" hx-trigger="every 5s">Status: ...</div>
<!-- Load once -->
<div hx-get="/content" hx-trigger="load">...</div>
Common Patterns
Form Submission
Pattern: Submit form via AJAX, replace form with result
<form hx-post="/api/v1/characters"
hx-target="#form-container"
hx-swap="outerHTML">
<input type="text" name="name" placeholder="Character Name" required>
<select name="class">
<option value="vanguard">Vanguard</option>
<option value="luminary">Luminary</option>
</select>
<button type="submit">Create Character</button>
</form>
Backend Response:
@characters_bp.route('/', methods=['POST'])
def create_character():
# Create character via API
response = api_client.post('/characters', request.form)
if response.status_code == 200:
character = response.json()['result']
return render_template('partials/character_card.html', character=character)
else:
return render_template('partials/form_error.html', error=response.json()['error'])
Delete with Confirmation
Pattern: Confirm before deleting, remove element on success
<div class="character-card" id="char-{{ character.character_id }}">
<h3>{{ character.name }}</h3>
<button hx-delete="/api/v1/characters/{{ character.character_id }}"
hx-confirm="Are you sure you want to delete {{ character.name }}?"
hx-target="closest .character-card"
hx-swap="outerHTML swap:1s">
Delete
</button>
</div>
Backend Response (empty for delete):
@characters_bp.route('/<character_id>', methods=['DELETE'])
def delete_character(character_id):
api_client.delete(f'/characters/{character_id}')
return '', 200 # Empty response removes element
Search/Filter
Pattern: Live search with debouncing
<input type="text"
name="search"
placeholder="Search characters..."
hx-get="/characters/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#character-list"
hx-indicator="#search-spinner">
<span id="search-spinner" class="htmx-indicator">Searching...</span>
<div id="character-list">
<!-- Results appear here -->
</div>
Backend:
@characters_bp.route('/search')
def search_characters():
query = request.args.get('search', '')
characters = api_client.get(f'/characters?search={query}')
return render_template('partials/character_list.html', characters=characters)
Pagination
Pattern: Load more items
<div id="character-list">
{% for character in characters %}
{% include 'partials/character_card.html' %}
{% endfor %}
</div>
{% if has_more %}
<button hx-get="/characters?page={{ page + 1 }}"
hx-target="#character-list"
hx-swap="beforeend"
hx-select=".character-card">
Load More
</button>
{% endif %}
Inline Edit
Pattern: Click to edit, save inline
<div class="character-name" id="name-{{ character.character_id }}">
<span hx-get="/characters/{{ character.character_id }}/edit-name"
hx-target="closest div"
hx-swap="outerHTML">
{{ character.name }} ✏️
</span>
</div>
Edit Form Response:
<div class="character-name" id="name-{{ character.character_id }}">
<form hx-put="/characters/{{ character.character_id }}/name"
hx-target="closest div"
hx-swap="outerHTML">
<input type="text" name="name" value="{{ character.name }}" autofocus>
<button type="submit">Save</button>
<button type="button" hx-get="/characters/{{ character.character_id }}/name" hx-target="closest div" hx-swap="outerHTML">Cancel</button>
</form>
</div>
Modal Dialog
Pattern: Load modal content dynamically
<button hx-get="/characters/{{ character.character_id }}/details"
hx-target="#modal-container"
hx-swap="innerHTML">
View Details
</button>
<div id="modal-container"></div>
Modal Response:
<div class="modal" id="character-modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">×</span>
<h2>{{ character.name }}</h2>
<!-- character details -->
</div>
</div>
<script>
function closeModal() {
document.getElementById('character-modal').remove();
}
</script>
Tabs
Pattern: Tab switching without page reload
<div class="tabs">
<button hx-get="/dashboard/overview"
hx-target="#tab-content"
class="tab active">
Overview
</button>
<button hx-get="/dashboard/characters"
hx-target="#tab-content"
class="tab">
Characters
</button>
<button hx-get="/dashboard/sessions"
hx-target="#tab-content"
class="tab">
Sessions
</button>
</div>
<div id="tab-content">
<!-- Tab content loads here -->
</div>
Polling for Updates
Pattern: Auto-refresh session status
<div hx-get="/sessions/{{ session_id }}/status"
hx-trigger="every 5s"
hx-target="#session-status">
Loading status...
</div>
Conditional Polling (stop when complete):
<div hx-get="/sessions/{{ session_id }}/status"
hx-trigger="every 5s[status !== 'completed']"
hx-target="#session-status">
Status: {{ session.status }}
</div>
Infinite Scroll
Pattern: Load more as user scrolls
<div id="character-list">
{% for character in characters %}
{% include 'partials/character_card.html' %}
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/characters?page={{ page + 1 }}"
hx-trigger="revealed"
hx-target="#character-list"
hx-swap="beforeend">
Loading more...
</div>
{% endif %}
Advanced Patterns
Dependent Dropdowns
Pattern: Update second dropdown based on first selection
<select name="class"
hx-get="/characters/abilities"
hx-target="#ability-select"
hx-trigger="change">
<option value="vanguard">Vanguard</option>
<option value="luminary">Luminary</option>
</select>
<div id="ability-select">
<!-- Abilities dropdown loads here based on class -->
</div>
Out of Band Swaps
Pattern: Update multiple areas from single response
<button hx-post="/combat/attack"
hx-target="#combat-log"
hx-swap="beforeend">
Attack
</button>
<div id="combat-log"><!-- Combat log --></div>
<div id="character-hp">HP: 50/100</div>
Backend Response:
<!-- Primary swap target -->
<div>You dealt 15 damage!</div>
<!-- Out of band swap -->
<div id="character-hp" hx-swap-oob="true">HP: 45/100</div>
Optimistic UI
Pattern: Show result immediately, revert on error
<button hx-post="/characters/{{ character_id }}/favorite"
hx-target="#favorite-btn"
hx-swap="outerHTML"
class="btn">
⭐ Favorite
</button>
Immediate feedback with CSS:
.htmx-request .htmx-indicator {
display: inline;
}
Progressive Enhancement
Pattern: Fallback to normal form submission
<form action="/characters" method="POST"
hx-post="/characters"
hx-target="#result">
<!-- If HTMX fails, form still works via normal POST -->
<input type="text" name="name">
<button type="submit">Create</button>
</form>
HTMX + Appwrite Realtime
Hybrid Approach
Use HTMX for user actions, Appwrite Realtime for server push updates:
<!-- HTMX for user actions -->
<button hx-post="/sessions/{{ session_id }}/ready"
hx-target="#lobby-status">
Ready
</button>
<!-- Appwrite Realtime for live updates -->
<script>
const client = new Appwrite.Client()
.setEndpoint('{{ config.appwrite_endpoint }}')
.setProject('{{ config.appwrite_project_id }}');
client.subscribe('channel', response => {
// Trigger HTMX to reload specific section
htmx.ajax('GET', '/sessions/{{ session_id }}/status', {
target: '#session-status',
swap: 'innerHTML'
});
});
</script>
Error Handling
Show Error Messages
<form hx-post="/characters"
hx-target="#form-container"
hx-target-error="#error-container">
<!-- form fields -->
</form>
<div id="error-container"></div>
Backend Error Response:
@characters_bp.route('/', methods=['POST'])
def create_character():
try:
# Create character
pass
except ValidationError as e:
response = make_response(render_template('partials/error.html', error=str(e)), 400)
response.headers['HX-Retarget'] = '#error-container'
return response
Retry on Failure
<div hx-get="/api/v1/data"
hx-trigger="load, error from:body delay:5s">
Loading...
</div>
Loading Indicators
Global Indicator
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
</style>
<button hx-post="/action" hx-indicator="#spinner">
Submit
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>
Inline Indicator
<button hx-get="/content">
<span class="button-text">Load</span>
<span class="htmx-indicator">Loading...</span>
</button>
Best Practices
1. Use Semantic HTML
<!-- Good -->
<button hx-post="/action">Submit</button>
<!-- Avoid -->
<div hx-post="/action">Submit</div>
2. Provide Fallbacks
<form action="/submit" method="POST" hx-post="/submit">
<!-- Works without JavaScript -->
</form>
3. Use hx-indicator for Loading States
<button hx-post="/action" hx-indicator="#spinner">
Submit
</button>
<span id="spinner" class="htmx-indicator">⏳</span>
4. Debounce Search Inputs
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms">
5. Use CSRF Protection
<form hx-post="/action">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
Debugging
HTMX Events
Listen to HTMX events for debugging:
<script>
document.body.addEventListener('htmx:beforeRequest', (event) => {
console.log('Before request:', event.detail);
});
document.body.addEventListener('htmx:afterRequest', (event) => {
console.log('After request:', event.detail);
});
document.body.addEventListener('htmx:responseError', (event) => {
console.error('Response error:', event.detail);
});
</script>
HTMX Logger Extension
<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
<body hx-ext="debug">
Performance Tips
1. Use hx-select to Extract Partial
<button hx-get="/full-page"
hx-select="#content-section"
hx-target="#result">
Load Section
</button>
2. Disable During Request
<button hx-post="/action" hx-disable-during-request>
Submit
</button>
3. Use hx-sync for Sequential Requests
<div hx-sync="this:replace">
<button hx-get="/content">Load</button>
</div>
Related Documentation
- TEMPLATES.md - Template structure and conventions
- TESTING.md - Manual testing guide
- /api/docs/API_REFERENCE.md - API endpoints
- HTMX Official Docs - Complete HTMX documentation
Document Version: 1.1 Created: November 18, 2025 Last Updated: November 21, 2025