FastHTML with HTMX in Python: Build Interactive Web Apps Without JavaScript

FastHTML with HTMX in Python: Build Interactive Web Apps Without JavaScript

I never thought that building web apps could be this enjoyable until I found FastHTML. This sleek, Python-first framework combines naturally with HTMX. It makes interactive web development feel almost magical.

This following blog is my highest earning related to FastHTML.

Article content

Why I Fell in Love

  • All in Python. No more juggling HTML templates or wrestling with JavaScript frameworks. You write interfaces almost entirely in Python, and it handles the rest. That simplicity gave me confidence from the very first minute.
  • HTML as Python objects. Imagine building a page with Div(P("Hello world"))—just like that, familiar Python code translates directly to HTML. It’s elegant and readable.
  • HTMX baked in. FastHTML isn’t just a static renderer. It includes HTMX out-of-the-box, unlocking super-smooth interactivity without ever writing JavaScript.

First Steps Getting Started

First, let’s get FastHTML installed:

pip install python-fasthtml        

Building Our First Route: Hello World That Actually Does Something

Here’s where FastHTML blew my mind. Check this out:

from fasthtml.common import fast_app, Titled, Div, H1, Form, Input, Button, serve

# Create app and router
app, rt = fast_app()

@rt("/")
def get():
    return Titled(
        "My Todo App",
        Div(
            H1("Todo List"),
            Form(
                Input(placeholder="Add a new todo", name="todo"),
                Button("Add", type="submit"),
                hx_post="/add-todo",
                hx_target="#todo-list",
                hx_swap="beforeend"
            ),
            Div(id="todo-list"),
        )
    )

if __name__ == "__main__":
    serve()        

Run this with python app.py and boom - you have a web server running on localhost:8000. But we're just getting started.

Article content

The Magic: Adding Todos Without Page Refresh

Here’s where HTMX shines. Look at this route that handles adding todos:

from fasthtml.common import fast_app, Div, Input, Span, Button

app, rt = fast_app()

# Store todos in memory (temporary)
todos = {}

@rt("/add-todo")
def post(todo: str):
    if not todo.strip():
        return ""
    
    todo_id = len(todos) + 1  # Simple ID generation
    todos[todo_id] = {"text": todo, "done": False}
    
    return Div(
        Input(
            type="checkbox", 
            hx_put=f"/toggle-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML"
        ),
        Span(todo),
        Button(
            "Delete", 
            hx_delete=f"/delete-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML swap:1s"
        ),
        id=f"todo-{todo_id}",
        style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
    )        

Wait, what just happened? Let me break it down:

  1. hx_post="/add-todo" - When form submits, POST to this route
  2. hx_target="#todo-list" - Put the response inside the todo-list div
  3. hx_swap="beforeend" - Add it to the end of existing content

No fetch(), no useState(), no useEffect(). Just HTML attributes that make sense.

Article content

Let’s Make It Interactive: Toggle and Delete

Here are the routes that handle the interactive parts:

from fasthtml.common import fast_app, Div, Input, Span, Button

app, rt = fast_app()

# Store todos in memory (temporary — use DB in real apps)
todos = {}

@rt("/toggle-todo/{todo_id}")
def put(todo_id: int):
    if todo_id in todos:
        todos[todo_id]["done"] = not todos[todo_id]["done"]

        return Div(
            Input(
                type="checkbox", 
                checked=todos[todo_id]["done"],
                hx_put=f"/toggle-todo/{todo_id}",
                hx_target="closest div",
                hx_swap="outerHTML"
            ),
            Span(
                todos[todo_id]["text"],
                style="text-decoration: line-through" if todos[todo_id]["done"] else ""
            ),
            Button(
                "Delete", 
                hx_delete=f"/delete-todo/{todo_id}",
                hx_target="closest div",
                hx_swap="outerHTML swap:1s"
            ),
            id=f"todo-{todo_id}",
            style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
        )
    return ""

@rt("/delete-todo/{todo_id}")
def delete(todo_id: int):
    if todo_id in todos:
        del todos[todo_id]
    return ""  # Returning empty tells HTMX to remove the element        

The Complete Working App

Here’s the full application that actually works:

from fasthtml.common import fast_app, Titled, Div, H1, Form, Input, Button, Span, serve

# Create app and route decorator
app, rt = fast_app()

# Simple in-memory storage (replace with DB in production)
todos = {}

@rt("/")
def get():
    # Build todo item list
    todo_items = []
    for todo_id, todo_data in todos.items():
        todo_items.append(
            Div(
                Input(
                    type="checkbox",
                    checked=todo_data["done"],
                    hx_put=f"/toggle-todo/{todo_id}",
                    hx_target="closest div",
                    hx_swap="outerHTML"
                ),
                Span(
                    todo_data["text"],
                    style="text-decoration: line-through" if todo_data["done"] else ""
                ),
                Button(
                    "Delete",
                    hx_delete=f"/delete-todo/{todo_id}",
                    hx_target="closest div",
                    hx_swap="outerHTML swap:1s"
                ),
                id=f"todo-{todo_id}",
                style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
            )
        )

    return Titled(
        "My Todo App",
        Div(
            H1("Todo List", style="color: #333;"),
            Form(
                Input(
                    placeholder="Add a new todo",
                    name="todo",
                    style="padding: 8px; margin-right: 10px;"
                ),
                Button(
                    "Add",
                    type="submit",
                    style="padding: 8px 15px; background: #007bff; color: white; border: none;"
                ),
                hx_post="/add-todo",
                hx_target="#todo-list",
                hx_swap="beforeend",
                style="margin-bottom: 20px;"
            ),
            Div(*todo_items, id="todo-list"),
            style="max-width: 600px; margin: 0 auto; padding: 20px;"
        )
    )

@rt("/add-todo")
def post(todo: str):
    if not todo.strip():
        return ""

    todo_id = max(todos.keys(), default=0) + 1
    todos[todo_id] = {"text": todo, "done": False}

    return Div(
        Input(
            type="checkbox",
            hx_put=f"/toggle-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML"
        ),
        Span(todo),
        Button(
            "Delete",
            hx_delete=f"/delete-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML swap:1s"
        ),
        id=f"todo-{todo_id}",
        style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
    )

@rt("/toggle-todo/{todo_id}")
def put(todo_id: int):
    if todo_id in todos:
        todos[todo_id]["done"] = not todos[todo_id]["done"]

        return Div(
            Input(
                type="checkbox",
                checked=todos[todo_id]["done"],
                hx_put=f"/toggle-todo/{todo_id}",
                hx_target="closest div",
                hx_swap="outerHTML"
            ),
            Span(
                todos[todo_id]["text"],
                style="text-decoration: line-through" if todos[todo_id]["done"] else ""
            ),
            Button(
                "Delete",
                hx_delete=f"/delete-todo/{todo_id}",
                hx_target="closest div",
                hx_swap="outerHTML swap:1s"
            ),
            id=f"todo-{todo_id}",
            style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
        )
    return ""

@rt("/delete-todo/{todo_id}")
def delete(todo_id: int):
    if todo_id in todos:
        del todos[todo_id]
    return ""

if __name__ == "__main__":
    serve()        

Let’s Add Some Database Action

Using SQLite because it’s simple and gets the job done:

import sqlite3
from fasthtml.common import fast_app, Titled, Div, H1, Form, Input, Button, Span, serve

# ---------- Database Functions ----------
def init_db():
    conn = sqlite3.connect("todos.db")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            text TEXT NOT NULL,
            done BOOLEAN DEFAULT FALSE
        )
    """)
    conn.commit()
    conn.close()

def get_todos():
    conn = sqlite3.connect("todos.db")
    cursor = conn.execute("SELECT id, text, done FROM todos")
    todos = [
        {"id": row[0], "text": row[1], "done": bool(row[2])}
        for row in cursor.fetchall()
    ]
    conn.close()
    return todos

def add_todo(text):
    conn = sqlite3.connect("todos.db")
    cursor = conn.execute("INSERT INTO todos (text) VALUES (?)", (text,))
    todo_id = cursor.lastrowid
    conn.commit()
    conn.close()
    return todo_id

def toggle_todo(todo_id):
    conn = sqlite3.connect("todos.db")
    conn.execute("UPDATE todos SET done = NOT done WHERE id = ?", (todo_id,))
    conn.commit()
    conn.close()

def delete_todo(todo_id):
    conn = sqlite3.connect("todos.db")
    conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
    conn.commit()
    conn.close()

# Initialize DB at startup
init_db()

# ---------- App Setup ----------
app, rt = fast_app()

@rt("/")
def get():
    todo_items = []
    for todo in get_todos():
        todo_items.append(
            Div(
                Input(
                    type="checkbox",
                    checked=todo["done"],
                    hx_put=f"/toggle-todo/{todo['id']}",
                    hx_target="closest div",
                    hx_swap="outerHTML"
                ),
                Span(
                    todo["text"],
                    style="text-decoration: line-through" if todo["done"] else ""
                ),
                Button(
                    "Delete",
                    hx_delete=f"/delete-todo/{todo['id']}",
                    hx_target="closest div",
                    hx_swap="outerHTML swap:1s"
                ),
                id=f"todo-{todo['id']}",
                style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
            )
        )

    return Titled(
        "My Todo App",
        Div(
            H1("Todo List", style="color: #333;"),
            Form(
                Input(
                    placeholder="Add a new todo",
                    name="todo",
                    style="padding: 8px; margin-right: 10px;"
                ),
                Button(
                    "Add",
                    type="submit",
                    style="padding: 8px 15px; background: #007bff; color: white; border: none;"
                ),
                hx_post="/add-todo",
                hx_target="#todo-list",
                hx_swap="beforeend",
                style="margin-bottom: 20px;"
            ),
            Div(*todo_items, id="todo-list"),
            style="max-width: 600px; margin: 0 auto; padding: 20px;"
        )
    )

@rt("/add-todo")
def post(todo: str):
    if not todo.strip():
        return ""
    todo_id = add_todo(todo)
    return Div(
        Input(
            type="checkbox",
            hx_put=f"/toggle-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML"
        ),
        Span(todo),
        Button(
            "Delete",
            hx_delete=f"/delete-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML swap:1s"
        ),
        id=f"todo-{todo_id}",
        style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
    )

@rt("/toggle-todo/{todo_id}")
def put(todo_id: int):
    toggle_todo(todo_id)
    for todo in get_todos():
        if todo["id"] == todo_id:
            return Div(
                Input(
                    type="checkbox",
                    checked=todo["done"],
                    hx_put=f"/toggle-todo/{todo_id}",
                    hx_target="closest div",
                    hx_swap="outerHTML"
                ),
                Span(
                    todo["text"],
                    style="text-decoration: line-through" if todo["done"] else ""
                ),
                Button(
                    "Delete",
                    hx_delete=f"/delete-todo/{todo_id}",
                    hx_target="closest div",
                    hx_swap="outerHTML swap:1s"
                ),
                id=f"todo-{todo_id}",
                style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
            )
    return ""

@rt("/delete-todo/{todo_id}")
def delete(todo_id: int):
    delete_todo(todo_id)
    return ""

if __name__ == "__main__":
    serve()        

Real-Time Updates with WebSockets

Want to make it collaborative? Here’s how you add real-time updates:

import sqlite3
from fasthtml.common import fast_app, Titled, Div, H1, Form, Input, Button, Span, serve

# ---------- Database Functions ----------
def init_db():
    conn = sqlite3.connect("todos.db")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            text TEXT NOT NULL,
            done BOOLEAN DEFAULT FALSE
        )
    """)
    conn.commit()
    conn.close()

def get_todos():
    conn = sqlite3.connect("todos.db")
    cursor = conn.execute("SELECT id, text, done FROM todos")
    todos = [
        {"id": row[0], "text": row[1], "done": bool(row[2])}
        for row in cursor.fetchall()
    ]
    conn.close()
    return todos

def add_todo(text):
    conn = sqlite3.connect("todos.db")
    cursor = conn.execute("INSERT INTO todos (text) VALUES (?)", (text,))
    todo_id = cursor.lastrowid
    conn.commit()
    conn.close()
    return todo_id

def toggle_todo(todo_id):
    conn = sqlite3.connect("todos.db")
    conn.execute("UPDATE todos SET done = NOT done WHERE id = ?", (todo_id,))
    conn.commit()
    conn.close()

def delete_todo(todo_id):
    conn = sqlite3.connect("todos.db")
    conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
    conn.commit()
    conn.close()

# Initialize DB at startup
init_db()

# ---------- App Setup ----------
app, rt = fast_app(ws_hdr=True)  # Enable WebSocket support

def render_todo_item(todo_id: int, text: str, done: bool):
    return Div(
        Input(
            type="checkbox",
            checked=done,
            hx_put=f"/toggle-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML"
        ),
        Span(
            text,
            style="text-decoration: line-through" if done else ""
        ),
        Button(
            "Delete",
            hx_delete=f"/delete-todo/{todo_id}",
            hx_target="closest div",
            hx_swap="outerHTML swap:1s"
        ),
        id=f"todo-{todo_id}",
        style="margin: 10px 0; padding: 10px; border: 1px solid #ccc;"
    )

@rt("/")
def get():
    todo_items = [render_todo_item(t["id"], t["text"], t["done"]) for t in get_todos()]

    return Titled(
        "My Todo App (Live Updates)",
        Div(
            H1("Todo List", style="color: #333;"),
            Form(
                Input(
                    placeholder="Add a new todo",
                    name="todo",
                    style="padding: 8px; margin-right: 10px;"
                ),
                Button(
                    "Add",
                    type="submit",
                    style="padding: 8px 15px; background: #007bff; color: white; border: none;"
                ),
                hx_post="/add-todo",
                hx_target="#todo-list",
                hx_swap="beforeend",
                style="margin-bottom: 20px;"
            ),
            Div(*todo_items, id="todo-list"),
            style="max-width: 600px; margin: 0 auto; padding: 20px;"
        )
    )

@rt("/add-todo")
def post(todo: str):
    if not todo.strip():
        return ""

    todo_id = add_todo(todo)
    todo_html = render_todo_item(todo_id, todo, False)

    # Notify all connected WebSocket clients
    app.ws_send(f"new_todo:{todo_id}:{todo}")

    return todo_html

@rt("/toggle-todo/{todo_id}")
def put(todo_id: int):
    toggle_todo(todo_id)
    for todo in get_todos():
        if todo["id"] == todo_id:
            return render_todo_item(todo_id, todo["text"], todo["done"])
    return ""

@rt("/delete-todo/{todo_id}")
def delete(todo_id: int):
    delete_todo(todo_id)
    app.ws_send(f"delete_todo:{todo_id}")
    return ""

@rt("/ws")
def ws(msg: str):
    # Echo received WebSocket message (or process it)
    return f"Server received: {msg}"

if __name__ == "__main__":
    serve()        

What Makes It Special in Real Use

  • Honest complexity growth. The framework is light at the start and grows gracefully no need to over-engineer early.
  • Full control at your fingertips. Need to inject custom JS, bypass some UI, or hack something unique? FastHTML is transparent enough to let you do it.
  • Community favorites. Developers enjoy how incremental the complexity is, plus how comfortably it layers over Python, HTMX, ASGI, Uvicorn, Starlette, and FastAPI-style design.

What I Learned Building This

  1. No JavaScript fatigue: Everything stays in Python. My brain doesn’t need to context-switch.
  2. HTMX is powerful: Those hx_* attributes handle AJAX, animations, and DOM updates seamlessly.
  3. Debugging is easier: When something breaks, I’m debugging Python, not wrestling with React dev tools.
  4. Performance is solid: Server-side rendering + partial updates = fast and responsive.

When This Approach Rocks

  • CRUD applications (which is 80% of web development)
  • Admin dashboards
  • Prototypes that need to work quickly
  • Teams that know Python better than JavaScript

When to Stick with JavaScript

  • Heavy client-side interactions (games, real-time collaboration)
  • Offline-first applications
  • When you need complex client-side state management

Try It Yourself

Copy that code, run it, and see how it feels. Build something small. Add features. Break things and fix them.

The beauty of FastHTML + HTMX is that you can build real, interactive web applications without getting lost in JavaScript complexity. Sometimes, the best solution is the simplest one that works.

Wrapping Up Why FastHTML with HTMX Feels Like Home

  • Pure Python joy: You write everything in one language, and things just work.
  • Minimal but powerful: From prototypes to dashboards, this stack scales up cleanly.
  • Real interactivity, no bloat: HTMX gives you dynamic behavior with HTML attributes not heavy JS.

If you’re a Python developer looking to build web interfaces without getting frustrated by frontend tools, I recommend trying FastHTML. I guarantee you’ll be smiling by the time you finish your first “Hello World” project.

A Note From the Author

Thank you so much for taking the time to read the story. If you found my article helpful and interesting, please share your thoughts in the comment section, and don’t forget to share and clap 😊

To view or add a comment, sign in

More articles by Gajanan Patil

Others also viewed

Explore content categories