This tutorial looks at how use HTMX with FastAPI by creating a simple todo web app and deploying it on Render.
Contents
FastAPI and HTMX
FastAPI is a fast and modern Python framework for creating APIs -- similar to Flask and Sanic. HTMX pairs well with FastAPI by adding server-side rendering capabilities to your HTML. So, instead of returning JSON and rendering it client side in JavaScript, you'll simply return HTML fragments which are swapped directly into the DOM. This does require a different thought paradigm when developing your application if you're used to writing endpoints that exclusively return JSON.
The advantages of pairing FastAPI and HTMX is that you're able to minimize the amount of client-side business logic you need to implement because HTMX shifts that to the server. So, you get the speed and user experience of a modern Single Page Application (SPA) but implemented with a more traditional server-side rendered paradigm.
As a quick aside, one of the first questions you might have (like I did) is how do you know when to return HTML or JSON from a particular endpoint? Unless you're building from scratch, sprinkling in HTMX into your codebase is probably the reality, so being able to programmatically respond with HTML or JSON is critical. This is done by checking for the
HX-Request
HTTP header. It'll always be set with a value oftrue
as per the docs. So, when theHX-Request
is present and set totrue
, return HTML otherwise return JSON.
Getting Started with FastAPI
There's a complete GitHub repository of the full source code along with a live instance hosted on Render:
- GitHub repository: https://github.com/Pinjasaur/fastapi-htmx-todo
- Render.com hosted instance: https://fastapi-htmx-todo.onrender.com
For this tutorial, we'll be using Python 3.12:
$ python --version
Python 3.12.4
Let's quickly spin up a fresh app:
$ mkdir fastapi-htmx-todo && cd fastapi-htmx-todo
$ python3 -m venv venv
$ source venv/bin/activate
(venv)$
(venv)$ pip install fastapi==0.111.0 jinja2==3.1.4
Feel free to swap out virtualenv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.
Create a new file for our app logic called main.py:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request=request, name="index.html")
Next, we need to create an index.html in a "templates" directory so Jinja2 will render it. For now, that content can be whatever you want. A simple <p>Hello, world!</p>
will suffice.
Spin up the development server in your terminal:
(venv)$ fastapi dev main.py
Load up http://localhost:8000/ to see it in action.
However, we're planning on creating a simple todo app. Let's mock up how that could look from a typical JSON endpoint response.
First, create a basic Todo
model and declare one in-memory:
class Todo:
def __init__(self, text: str):
self.id = uuid4()
self.text = text
self.done = False
todos = [Todo("test")]
Don't forget the import:
from uuid import uuid4
Then, create a GET endpoint for /todos
:
@app.get("/todos", response_class=HTMLResponse)
async def list_todos(request: Request):
return JSONResponse(content=jsonable_encoder(todos))
Add the appropriate imports:
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse, JSONResponse
Now, using your HTTP client of choice, make a request and you'll see the response:
$ curl localhost:8000/todos
[{"id":"ba03c632-d8a6-4e0e-ade5-e70775dab593","text":"test","done":false}]
Let's make that same logic work with HTMX.
Integrating HTMX
HTMX v2 was just released so we'll be using that. There weren't many breaking changes, but if you've used HTMX v1 previously, it's worth referencing the migration guide.
Let's set up some Jinja2 templates to support this.
First, update templates/index.html like so:
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FastAPI + HTMX</title>
<script src="https://unpkg.com/[email protected]" integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw" crossorigin="anonymous"></script>
</head>
<body>
<!-- We'll add some markup soon -->
</body>
</html>
This will be our bare document that includes HTMX as a dependency in the <head>
.
It's not required, but you could choose to add a classless CSS library like simple.css for some basic out-of-the-box styling:
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
Now, add an HTML list to populate our todos in the <body>
:
<ul id="todos" hx-get="/todos" hx-swap="innerHTML" hx-trigger="load"></ul>
One last bit of boilerplate, let's create another Jinja2 template for our todos called templates/todos.html:
<!-- templates/todos.html -->
{% for todo in todos %}
<li>
{{todo.text}}
</li>
{% endfor %}
Finally, let's wire that up into our existing /todos
endpoint. Remember: We'll need to check for the HX-Request
header in FastAPI to return the HTML fragment instead of the JSON payload. Our list_todos
FastAPI route becomes:
@app.get("/todos", response_class=HTMLResponse)
async def list_todos(request: Request, hx_request: Annotated[Union[str, None], Header()] = None):
if hx_request:
return templates.TemplateResponse(
request=request, name="todos.html", context={"todos": todos}
)
return JSONResponse(content=jsonable_encoder(todos))
Add the imports:
from typing import Annotated, Union
from fastapi import FastAPI, Header, Request
With this, you'll get a bare list of your todos and their text content. Test it out in your browser.
More CRUD with HTMX
Now that we're able to do the basics of listing todos, let's implement creating, editing (including text content and done or not-done), and deleting. We'll start with creating.
Let's start with the FastAPI logic by creating a create_todo
route:
@app.post("/todos", response_class=HTMLResponse)
async def create_todo(request: Request, todo: Annotated[str, Form()]):
todos.append(Todo(todo))
return templates.TemplateResponse(
request=request, name="todos.html", context={"todos": todos}
)
Import:
from fastapi import FastAPI, Form, Request, Header
Now we can add an HTML <form>
, to index.html and use HTMX to wire it up:
<form hx-post="/todos" hx-target="#todos" hx-swap="innerHTML" hx-on:htmx:after-request="this.reset()">
<input type="text" name="todo" placeholder="I'd like to...">
<button>Create</button>
</form>
We're telling HTMX to POST to /todos
on <form>
submit and swap the contents into the same #todos
element as before. Also, clear the <input>
as a nice bit of UX.
Now that we have this, we don't need to hardcode any todos in our app and instead can create them dynamically in the UI:
-todos = [Todo("test")]
+todos = []
Before we implement toggling and deleting todos, let's add the ability edit the todo text itself. We'll start by creating a new route:
@app.put("/todos/{todo_id}", response_class=HTMLResponse)
async def update_todo(request: Request, todo_id: str, text: Annotated[str, Form()]):
for index, todo in enumerate(todos):
if str(todo.id) == todo_id:
todo.text = text
break
return templates.TemplateResponse(
request=request, name="todos.html", context={"todos": todos}
)
The business logic here is straightforward: We pass in a todo's id
as part of the URL, enumerate over the list of todos we have, and if we find a match then we update the text to what was passed in as part of the request. And finally, we return the rendered todos.html template.
Now, we need to update the todos.html template to use this new route:
{% for todo in todos %}
<li>
<input name="text" value="{{todo.text}}" hx-put="/todos/{{todo.id}}" hx-target="#todos" hx-swap="innerHTML" hx-trigger="keyup changed delay:250ms">
</li>
{% endfor %}
This is similar pattern to creating a todo, except we're using the hx-trigger
attribute to specify on keyup
and changed
events, plus delay 250ms as a way to debounce the input. Notice: Unlike the <form>
, there's no need to reset()
because the contents itself will get replaced by the freshly rendered copy.
Now that we can edit todos, let's finish the rest of the functionality for toggling a todo as done and deleting todos.
Add the new routes:
@app.post("/todos/{todo_id}/toggle", response_class=HTMLResponse)
async def toggle_todo(request: Request, todo_id: str):
for index, todo in enumerate(todos):
if str(todo.id) == todo_id:
todos[index].done = not todos[index].done
break
return templates.TemplateResponse(
request=request, name="todos.html", context={"todos": todos}
)
@app.post("/todos/{todo_id}/delete", response_class=HTMLResponse)
async def delete_todo(request: Request, todo_id: str):
for index, todo in enumerate(todos):
if str(todo.id) == todo_id:
del todos[index]
break
return templates.TemplateResponse(
request=request, name="todos.html", context={"todos": todos}
)
The business logic for toggling and deleting is again similar to editing: enumerate the list, if we have a match then do some logic, and finally return the rendered HTML.
Update todos.html again:
{% for todo in todos %}
<li>
<input name="text" value="{{todo.text}}" {% if todo.done %}style="text-decoration: line-through" disabled="true"{% endif %} hx-put="/todos/{{todo.id}}" hx-target="#todos" hx-swap="innerHTML" hx-trigger="keyup changed delay:250ms">
<input type="checkbox" {% if todo.done %}checked="true"{% endif %} hx-post="/todos/{{todo.id}}/toggle" hx-target="#todos" hx-swap="innerHTML">
<input type="button" value="❌" hx-post="/todos/{{todo.id}}/delete" hx-target="#todos" hx-swap="innerHTML">
</li>
{% endfor %}
Notice that we updated our original <input>
to check for todo.done
and apply a ~~strikethrough~~ and set the disabled
HTML attribute.
Woohoo! We've implemented the basic CRUD functionality for our todo app. Let's recap:
- We set up FastAPI with a route returning our todos as JSON
- We added HTMX v2 and updated FastAPI to return our todos as HTML
- We implemented functionality to create, read, update, and delete our todos
Deploying to Render
Using Render.com, we're able to deploy our FastAPI application easily.
If you don't already have a Render account, go ahead and make one. It's free.
Then, create a new Web Service on the free tier. If your code is on GitHub you can choose to manually specify the public repository or connect your GitHub account directly to Render. Render automatically picks up the pip install -r requirements.txt
command but we need to specify the command for starting our app.
The FastAPI docs give us a hint:
$ fastapi run main.py
We'll need to make one tweak, however: Render by default expects the port to be bound to 10000
. By default, FastAPI binds to port 8000
, but we're able to use the $PORT
environment variable to let Render handle it:
$ fastapi run main.py --port $PORT
With this done, we're able to get our web app deployed. It might take a few minutes for it to fully deploy and accept traffic, so if you're impatient you can view the already deployed instance at https://fastapi-htmx-todo.onrender.com.
Conclusion
In this tutorial, you've learned about FastAPI, HTMX, and how we can use them together successfully to create a simple todo app with all the CRUD functionality you would expect. We covered starting with a route that returns JSON like you might be familiar with and modifying it to dynamically return HTML if HTMX requests it. And finally, we walked through how to implement the CRUD functionality in a more backend-focused paradigm for working with HTMX.
There are two existing packages worth mentioning: fasthx and fastapi-htmx. These packages both offer opinionated ways of writing FastAPI logic for HTMX by implementing common patterns as decorators. This plays nicely with vanilla FastAPI and reduces boilerplate and repetitive code. If you're curious about building more complex apps with FastAPI and HTMX, those packages are worth a look.