A Practical Guide to HTMX with Python's Air Framework
Building modern, dynamic web applications often feels like a choice between two extremes: a simple, static multi-page app or a complex, JavaScript-heavy single-page application (SPA). But what if there was a middle ground?
That's where HTMX comes in. It lets you access modern, dynamic features directly from HTML, without writing tons of JavaScript. When paired with a Python backend, it creates a powerful and productive stack.
In this guide, we'll explore the core concepts of HTMX by building a few simple, interactive examples using Air, a minimalist Python web framework designed for speed and ease of use.
What is Air?
Air is a modern Python web framework built directly on the shoulders of FastAPI. It draws inspiration from a variety of predecessors, including the component architecture of Dash, the simplified nature of Flask, the htmx support of fasthtml, and the pydantic form validation and pluggable systems from Django. And more
It uses a structured, component-based architecture, allowing you to build user interfaces with simple, reusable Python objects. This approach promotes clean, maintainable code and provides excellent support for modern development tools like autocompletion and type-checking.
A key benefit of its FastAPI foundation is the power to serve both rich HTML components and traditional JSON APIs from the same application, giving you incredible flexibility. Air's Python-native components make your UI code more robust and easier to reason about, and provide great IDE support due to high investment in typing and documentation. It’s designed to make real web development feel intuitive and fun again.
Now, let's lean about HTMX's big 4 and how to use them in air. The full code for this post can be found here
Example 1: hx-get
The s most fundamental HTMX attribute is hx-get. It tells an element, like a form or a button, to send a GET request to a specified URL when it's triggered (e.g., when a form is submitted).
By default, HTMX will replace the element that triggered the request with the HTML response from the server.
Let's look at a basic form. When the user enters their name and clicks "Submit," the form will be replaced by a greeting.
Here is the air code to create this functionality:
@app.get("/basic-form")
def basic_form():
return air.layouts.picocss(
...
air.Form(
air.Label(
"Name",
air.Input(name='name', type='text', required=True, placeholder="Enter your name")
),
air.Button("Submit", class_="primary"),
hx_get="/add-name"
),
...
)
@app.get("/add-name")
def add_name(name: str):
return air.H1(f"{name}!!!")
In the basic_form function, we create a <form> component. We add the hx_get="/add-name" attribute to it. This tells HTMX to send a GET request to our /add-name endpoint upon submission. The /add-name endpoint simply returns an <h1> component with the user's name, which then replaces the original form.
Example 2: hx-target
Replacing the entire form isn't always what you want. Often, you want to submit a form and see the results appear in a different part of the page. This is where hx-target comes in.
The hx-target attribute takes a CSS selector and tells HTMX to place the server's response inside the element matching that selector, instead of the element that triggered the request.
In this example, the form stays visible, and the greeting appears in a <div> below it.
The air code is a small modification of our first example:
@app.get("/basic-form2")
def basic_form2():
return air.layouts.picocss(
...
air.Form(
air.Label(
"Name",
air.Input(name='name', type='text', required=True, placeholder="Enter your name")
),
air.Button("Submit", class_="primary"),
hx_get="/add-name2",
hx_target='#result' # <- The key htmx command
),
air.Div(
air.P("Result will appear here:", style="color: #666;"),
id='result', # <- id matches the hx_target
),
...
)
@app.get("/add-name2")
def add_name2(name: str):
return air.H1(f"Hello {name}!!!")
We added hx_target='#result', pointing to the <div> with id='result'. Now, when the form is submitted, the <h1> from /add-name2 is placed inside our target <div>, leaving the form untouched.
Example 3: hx-swap
By default, hx-target replaces the entire inner content of the target element. But what if you want to add to the content instead of replacing it? That's the job of hx-swap.
The hx-swap attribute controls how the response is placed in the DOM. Common values include innerHTML (the default), outerHTML, and, for our use case, values like beforeend (appends the content at the end of the target) and afterbegin (prepends it at the beginning).
Here, each form submission adds a new greeting to the list without removing the old ones.
Here's the code:
@app.get("/basic-form3")
def basic_form3():
return air.layouts.picocss(
# ...
air.Form(
# ...
hx_get="/add-name3",
hx_target='#result',
hx_swap='beforeend' # <-- Add this!
),
air.Div(
air.P("Greetings will accumulate here:", style="color: #666;"),
id='result',
# ...
),
# ...
)
@app.get("/add-name3")
def add_name3(name: str):
return air.H2(f"Hello {name}!!!")
By setting hx_swap='beforeend', we tell HTMX to append the response from /add-name3 as the last child of our #result div, creating a running list of greetings.
Example 4: hx-trigger
So far, all our requests have been triggered by a form submission. The hx-trigger attribute lets you change what event causes the request. You can trigger requests on clicks, mouseovers, or, in this case, whenever an input's value changes.
This allows for powerful "live search" or "live preview" functionality without any JavaScript.
In this final example, the result updates in real-time as you type into the input field—no submit button needed!
This workflow is a bit different. We move the HTMX attributes directly onto the <input> element.
@app.get("/basic-form4")
def basic_form4():
return air.layouts.picocss(
# ...
air.Form(
air.Label(
"Name",
air.Input(
name='name',
type='text',
placeholder="Start typing your name...",
hx_get="/add-name4",
hx_target='#result',
hx_trigger='input changed' # <-- Live updates!
)
)
),
air.Div(
air.P("Live result will appear here:", style="color: #666;"),
id='result',
# ...
),
# ...
)
@app.get("/add-name4")
def add_name4(name: str):
if not name:
return air.P("Start typing to see the magic!", style="color: #999;")
return air.H2(f"Hello {name}!!!", style="color: #2196F3;")
We use hx_trigger='input changed'. The input event fires every time the value changes, and changed adds a slight debounce, so it doesn't fire on every single keystroke. The result is a smooth, responsive UI that sends a request to /add-name4 whenever you type, updating the #result div instantly.
📧 Stay Updated!
Weekly newsletter for an over-the-shoulder look at a project I'm building, a key lesson, and a practical refactor.