Back to Blog
Article

FastAPI and HTMX: Building Modern Web Apps Without JavaScript Fatigue

By ForgeAPI Team

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?

BenefitDescription
Python EverywhereWrite all your logic in Python
No Build StepNo webpack, no bundling, just HTML
SEO FriendlyServer-rendered content by default
Progressive EnhancementWorks without JavaScript
Fast DevelopmentRapid iteration without JS complexity
Smaller BundleHTMX is only 14kb gzipped

Project Setup

pip install fastapi uvicorn jinja2 python-multipart

Project Structure

project/
├── app/
│   ├── main.py
│   ├── templates/
│   │   ├── base.html
│   │   ├── index.html
│   │   └── partials/
│   │       ├── todo_list.html
│   │       └── todo_item.html
│   └── static/
│       └── htmx.min.js
└── requirements.txt

Basic 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 element

Advanced 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:
        pass

CSS 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 response

Production Tips

  1. Use hx-boost on your body tag for automatic AJAX navigation
  2. Implement proper loading states with htmx-indicator
  3. Add hx-push-url to update browser history
  4. Use hx-swap-oob for updating multiple elements
  5. 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.