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.
Why I Fell in Love
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.
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:
No fetch(), no useState(), no useEffect(). Just HTML attributes that make sense.
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
Recommended by LinkedIn
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
What I Learned Building This
When This Approach Rocks
When to Stick with JavaScript
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
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 😊
Well documented!