Tired of complex JavaScript frameworks? HTMX lets you build dynamic, modern web applications using HTML attributes and server-rendered responses. Combined with FastAPI, you get the best of both worlds: Python everywhere.
What is HTMX?
HTMX is a library that allows you to access modern browser features directly from HTML, without writing JavaScript. It extends HTML with attributes like hx-get, hx-post, and hx-swap to create dynamic interfaces.
<!-- This button fetches content and swaps it into the DOM -->
<button hx-get="/api/load-more"
hx-target="#content"
hx-swap="beforeend">
Load More
</button>Why FastAPI + HTMX?
| Benefit | Description |
|---|---|
| Python Everywhere | Write all your logic in Python |
| No Build Step | No webpack, no bundling, just HTML |
| SEO Friendly | Server-rendered content by default |
| Progressive Enhancement | Works without JavaScript |
| Fast Development | Rapid iteration without JS complexity |
| Smaller Bundle | HTMX is only 14kb gzipped |
Project Setup
pip install fastapi uvicorn jinja2 python-multipartProject Structure
project/
├── app/
│ ├── main.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── index.html
│ │ └── partials/
│ │ ├── todo_list.html
│ │ └── todo_item.html
│ └── static/
│ └── htmx.min.js
└── requirements.txtBasic Configuration
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
app = FastAPI()
# Mount static files
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# Setup Jinja2 templates
templates = Jinja2Templates(directory="app/templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse(
"index.html",
{"request": request, "title": "HTMX + FastAPI"}
)Base Template
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% raw %}{{ title }}{% endraw %}</title>
<!-- HTMX from CDN -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Optional: Alpine.js for client-side state -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen">
{% raw %}{% block content %}{% endblock %}{% endraw %}
</body>
</html>Building a Todo Application
The Main Page
<!-- templates/index.html -->
{% raw %}{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-8">Todo List</h1>
<!-- Add Todo Form -->
<form hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()"
class="flex gap-4 mb-8">
<input type="text"
name="title"
placeholder="What needs to be done?"
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 focus:ring-2 focus:ring-emerald-500 focus:outline-none"
required>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-500 px-6 py-2 rounded-lg font-medium transition-colors">
Add
</button>
</form>
<!-- Todo List -->
<ul id="todo-list" class="space-y-3">
{% for todo in todos %}
{% include "partials/todo_item.html" %}
{% endfor %}
</ul>
<!-- Loading indicator -->
<div id="loading" class="htmx-indicator text-center py-4">
<span class="animate-pulse">Loading...</span>
</div>
</div>
{% endblock %}{% endraw %}Todo Item Partial
<!-- templates/partials/todo_item.html -->
{% raw %}<li id="todo-{{ todo.id }}"
class="flex items-center gap-4 p-4 bg-gray-800 rounded-lg border border-gray-700 group">
<!-- Toggle completion -->
<input type="checkbox"
{% if todo.completed %}checked{% endif %}
hx-patch="/todos/{{ todo.id }}/toggle"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML"
class="w-5 h-5 rounded bg-gray-700 border-gray-600 text-emerald-500 focus:ring-emerald-500">
<!-- Title -->
<span class="flex-1 {% if todo.completed %}line-through text-gray-500{% endif %}">
{{ todo.title }}
</span>
<!-- Delete button -->
<button hx-delete="/todos/{{ todo.id }}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML swap:0.3s"
hx-confirm="Delete this todo?"
class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 transition-opacity">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</li>{% endraw %}FastAPI Endpoints
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from typing import List
import uuid
# Simple in-memory storage
todos: dict = {}
class Todo(BaseModel):
id: str
title: str
completed: bool = False
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"title": "Todo App",
"todos": list(todos.values())
}
)
@app.post("/todos", response_class=HTMLResponse)
async def create_todo(request: Request, title: str = Form(...)):
"""Create a new todo and return the HTML partial."""
todo = Todo(id=str(uuid.uuid4()), title=title)
todos[todo.id] = todo
return templates.TemplateResponse(
"partials/todo_item.html",
{"request": request, "todo": todo}
)
@app.patch("/todos/{todo_id}/toggle", response_class=HTMLResponse)
async def toggle_todo(request: Request, todo_id: str):
"""Toggle todo completion status."""
todo = todos.get(todo_id)
if todo:
todo.completed = not todo.completed
return templates.TemplateResponse(
"partials/todo_item.html",
{"request": request, "todo": todo}
)
return ""
@app.delete("/todos/{todo_id}", response_class=HTMLResponse)
async def delete_todo(todo_id: str):
"""Delete a todo and return empty response."""
todos.pop(todo_id, None)
return "" # Empty response removes the elementAdvanced HTMX Patterns
Infinite Scroll
<div id="posts-container">
{% raw %}{% for post in posts %}
{% include "partials/post_card.html" %}
{% endfor %}{% endraw %}
<!-- Trigger element -->
<div hx-get="/posts?page={% raw %}{{ next_page }}{% endraw %}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#loading">
</div>
</div>@app.get("/posts", response_class=HTMLResponse)
async def get_posts(request: Request, page: int = 1):
posts = await fetch_posts(page=page, limit=10)
has_more = len(posts) == 10
return templates.TemplateResponse(
"partials/posts_page.html",
{
"request": request,
"posts": posts,
"next_page": page + 1 if has_more else None
}
)Live Search
<input type="search"
name="q"
placeholder="Search..."
hx-get="/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner">
<div id="search-results"></div>
<span id="search-spinner" class="htmx-indicator">Searching...</span>@app.get("/search", response_class=HTMLResponse)
async def search(request: Request, q: str = ""):
if len(q) < 2:
return ""
results = await search_database(q)
return templates.TemplateResponse(
"partials/search_results.html",
{"request": request, "results": results, "query": q}
)Modal Dialogs
<!-- Trigger button -->
<button hx-get="/modals/edit-profile"
hx-target="body"
hx-swap="beforeend">
Edit Profile
</button>
<!-- Modal template -->
<div id="modal-backdrop"
class="fixed inset-0 bg-black/50 flex items-center justify-center"
hx-on:click="if(event.target === this) htmx.remove(this)">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Edit Profile</h2>
<form hx-put="/profile"
hx-target="#profile-section"
hx-on::after-request="htmx.remove('#modal-backdrop')">
<!-- Form fields -->
<input type="text" name="name" value="{% raw %}{{ user.name }}{% endraw %}">
<div class="flex gap-4 mt-6">
<button type="submit" class="btn-primary">Save</button>
<button type="button"
onclick="htmx.remove('#modal-backdrop')"
class="btn-secondary">
Cancel
</button>
</div>
</form>
</div>
</div>Optimistic UI Updates
@app.post("/like/{post_id}", response_class=HTMLResponse)
async def like_post(request: Request, post_id: str):
# Check the trigger to determine action
trigger = request.headers.get("HX-Trigger")
post = await get_post(post_id)
if "unlike" in trigger:
await remove_like(post_id, request.user.id)
post.likes_count -= 1
post.is_liked = False
else:
await add_like(post_id, request.user.id)
post.likes_count += 1
post.is_liked = True
return templates.TemplateResponse(
"partials/like_button.html",
{"request": request, "post": post}
)WebSocket Integration
HTMX supports WebSocket for real-time updates:
<div hx-ext="ws" ws-connect="/ws/notifications">
<div id="notifications"></div>
</div>from fastapi import WebSocket
@app.websocket("/ws/notifications")
async def websocket_notifications(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Wait for new notifications
notification = await get_next_notification()
# Send HTML directly
html = templates.get_template(
"partials/notification.html"
).render(notification=notification)
await websocket.send_text(
f'<div id="notifications" hx-swap-oob="afterbegin">{html}</div>'
)
except WebSocketDisconnect:
passCSS Transitions
HTMX works beautifully with CSS transitions:
/* Fade in new content */
.htmx-added {
opacity: 0;
}
.htmx-added.htmx-settling {
opacity: 1;
transition: opacity 0.3s ease-in;
}
/* Fade out removed content */
.htmx-swapping {
opacity: 0;
transition: opacity 0.3s ease-out;
}
/* Loading states */
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-indicator {
display: none;
}
/* Skeleton loading */
.htmx-request .skeleton {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}Authentication with HTMX
from fastapi import Depends, HTTPException
async def require_htmx(request: Request):
"""Ensure request comes from HTMX."""
if not request.headers.get("HX-Request"):
raise HTTPException(status_code=400, detail="HTMX request required")
@app.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
email: str = Form(...),
password: str = Form(...)
):
user = await authenticate(email, password)
if not user:
# Return error message partial
return templates.TemplateResponse(
"partials/login_error.html",
{"request": request, "error": "Invalid credentials"},
headers={"HX-Retarget": "#login-error"}
)
# Set session and redirect
response = HTMLResponse("")
response.set_cookie("session", create_session(user))
response.headers["HX-Redirect"] = "/dashboard"
return responseProduction Tips
- Use
hx-booston your body tag for automatic AJAX navigation - Implement proper loading states with
htmx-indicator - Add
hx-push-urlto update browser history - Use
hx-swap-oobfor updating multiple elements - Cache templates in production for performance
from functools import lru_cache
@lru_cache(maxsize=100)
def get_cached_template(template_name: str):
return templates.get_template(template_name)Conclusion
FastAPI and HTMX is a powerful combination that lets you build dynamic web applications without the complexity of modern JavaScript frameworks. You get the interactivity users expect while keeping your entire stack in Python.
Want the complete setup? ForgeAPI includes HTMX integration patterns, ready-to-use templates, and production configurations out of the box.