Dynamic Pages
Goals
- Understand how HTMX lets the browser swap content into a page without a full reload.
- Add a scrollable table that fetches more rows from the server as the user scrolls down.
- Show a detail pane below the table that updates when the user clicks on any row.
- Write server routes that return HTML fragments instead of complete pages.
What Is HTMX?
What problem does HTMX solve, and what would the same interaction look like without it?
- Every page built so far responds to user actions by reloading the entire page
- Clicking a link sends a GET request, and submitting a form sends a POST request
- In both cases the browser discards everything it has and renders a fresh page from scratch
- If you want to show a record's details when the user clicks a row,
the obvious approach is to link to a full detail page
- But that throws away the scroll position in the table
- HTMX is a JavaScript library that adds new behaviors to HTML elements through attributes
- Instead of reloading the whole page, HTMX sends a request to the server and inserts the returned content into one part of the existing page
- The server returns an HTML fragment
- Just the new rows or the updated panel, not a complete HTML page
- No build step, no bundler, no JavaScript to write: the behaviors live in the HTML attributes
How does HTMX get loaded, and what does a minimal HTMX attribute look like?
- HTMX is loaded with a single
<script>tag pointing to a content delivery network (CDN):
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
- Once loaded, HTMX scans the page for attributes that start with
hx-hx-get="/some/url": when this element is triggered, fetch that URLhx-triggerdetermines what causes the fetch:"click","change","revealed", and so onhx-targetselects which element in the page receives the responsehx-swapcontrols how the response is inserted:"innerHTML"replaces the target's contents,"outerHTML"replaces the target element itself
- In htpy, attribute names with hyphens are written with underscores:
hx_get,hx_trigger,hx_swap- htpy converts them back to hyphens in the generated HTML
Loading More Rows on Scroll
Create a server that shows the first 20 sightings in a fixed-height scrollable table. When the user scrolls to the bottom, the table should automatically request and display the next 20 rows.
- The table sits inside a
<div class="scroll-container">whose CSS setsheight: 400pxandoverflow-y: scroll, so only a few rows are visible at a time - The last
<tr>in the table body is a sentinel row that carries three HTMX attributes:hx-get="/rows?offset=20": the URL to fetch when this row becomes visiblehx-trigger="revealed": fire when this element scrolls into the visible area of its containerhx-swap="outerHTML": replace the sentinel row itself with whatever the server returns
PAGE_SIZE = 20is a named constant shared bymake_row,make_sentinel, and both routes- Using a named constant rather than a bare
20means changing one line adjusts the whole server
- Using a named constant rather than a bare
What does the
/rowsroute return, and how does it know when to stop adding sentinels?
/rows?offset=Nfetches the next page of rows from the database and returns raw<tr>elements- No
<html>,<head>, or<body>tags: just the rows - HTMX swaps this fragment directly into the table, replacing the sentinel with the new rows
- No
- If the page returned has exactly
PAGE_SIZErows, there may be more; the route appends a new sentinel row withoffsetincremented byPAGE_SIZE - If the page has fewer than
PAGE_SIZErows, this is the last page; no sentinel is added and scrolling stops
def make_row(row):
return tr[
td[str(row["id"])],
[td[fmt(row[k])] for k in KEYS[1:]],
]
def make_sentinel(offset):
"""Return a table row that triggers the next page load when scrolled into view."""
return tr(
hx_get=f"/rows?offset={offset}",
hx_trigger="revealed",
hx_swap="outerHTML",
)[td(colspan=len(HEADERS))["Loading..."]]
def make_app(db_path=DB_PATH):
@get("/", media_type=MediaType.HTML)
async def index() -> str:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"select * from sightings limit ?", [PAGE_SIZE]
).fetchall()
conn.close()
return str(
html(lang="en")[
head[
title["Sasquatch Sightings"],
link(rel="stylesheet", href="/style.css"),
script(src="https://unpkg.com/htmx.org@2.0.4"),
],
body[
h1["Sasquatch Sightings"],
div(class_="scroll-container")[
table[
thead[tr[[th[col] for col in HEADERS]]],
tbody[
[make_row(row) for row in rows],
make_sentinel(PAGE_SIZE) if len(rows) == PAGE_SIZE else "",
],
],
],
],
]
)
@get("/rows", media_type=MediaType.HTML)
async def more_rows(offset: int = 0) -> str:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"select * from sightings limit ? offset ?", [PAGE_SIZE, offset]
).fetchall()
conn.close()
result = "".join(str(make_row(row)) for row in rows)
if len(rows) == PAGE_SIZE:
result += str(make_sentinel(offset + PAGE_SIZE))
return result
@get("/style.css", media_type="text/css")
async def styles() -> str:
return (LESSON_DIR / "style.css").read_text()
return Litestar([index, more_rows, styles])
app = make_app()
- Start the server from the
htmx/directory:
litestar run --app server_detail:app
- Open
http://localhost:8000and scroll to the bottom of the table- The browser requests the next page without any page reload
- The new rows appear at the bottom as the old sentinel disappears
Showing a Detail Pane on Click
Add a panel below the table that shows the full record for any row the user clicks. The panel must update in place without reloading the page or affecting the scroll position.
- Copy
server_scroll.pytoserver_detail.pyand make two changes to the page - Each data row gets three HTMX attributes:
hx-get="/sighting/{id}/detail": the URL to fetch when this row is clickedhx-target="#detail": the CSS selector of the element to updatehx-swap="innerHTML": replace the contents of#detail, leaving the element itself in place
- The page adds
<div id="detail" class="detail-pane">below the scroll container, starting with a placeholder message - The CSS rule
tr[hx-target]:hovergives the rows a pointer cursor and a highlight color, so users know the rows are clickabletr[hx-target]is an attribute selector that matches any<tr>that has anhx-targetattribute- In
server_scroll.pyno rows havehx-target, so this rule has no effect there
Write the server route that returns the detail fragment.
GET /sighting/{sighting_id}/detailqueries the database for one record and returns a<table>of field-value pairs with no surrounding page structure- HTMX drops this directly into
#detail, replacing whatever was there before - The scroll position in the table is unchanged because HTMX only touched
#detail, which sits below the scroll container
- HTMX drops this directly into
- If the sighting ID does not exist, the route returns
<p>Sighting not found.</p>- A plain paragraph is enough; a full HTML error page would look broken inside the detail pane
def make_row(row):
return tr(
hx_get=f"/sighting/{row['id']}/detail",
hx_target="#detail",
hx_swap="innerHTML",
)[
td[str(row["id"])],
[td[fmt(row[k])] for k in KEYS[1:]],
]
def make_app(db_path=DB_PATH):
@get("/", media_type=MediaType.HTML)
async def index() -> str:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"select * from sightings limit ?", [PAGE_SIZE]
).fetchall()
conn.close()
return str(
html(lang="en")[
head[
title["Sasquatch Sightings"],
link(rel="stylesheet", href="/style.css"),
script(src="https://unpkg.com/htmx.org@2.0.4"),
],
body[
h1["Sasquatch Sightings"],
div(class_="scroll-container")[
table[
thead[tr[[th[col] for col in HEADERS]]],
tbody[
[make_row(row) for row in rows],
make_sentinel(PAGE_SIZE) if len(rows) == PAGE_SIZE else "",
],
],
],
div(id="detail", class_="detail-pane")[
p["Click any row to see the full record."]
],
],
]
)
@get("/sighting/{sighting_id:int}/detail", media_type=MediaType.HTML)
async def detail_fragment(sighting_id: int) -> str:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"select * from sightings where id = ?", [sighting_id]
).fetchone()
conn.close()
if row is None:
return "<p>Sighting not found.</p>"
return str(
table[
[
tr[
td(class_="label")[lbl],
td[fmt(row[key])],
]
for key, lbl in LABELS.items()
]
]
)
@get("/style.css", media_type="text/css")
async def styles() -> str:
return (LESSON_DIR / "style.css").read_text()
return Litestar([index, more_rows, detail_fragment, styles])
app = make_app()
See [htmx2025] for the full HTMX attribute reference.
Check Understanding
A classmate adds hx-trigger="click" to the sentinel row by mistake. What happens when the user scrolls, and what should the sentinel's trigger be?
The sentinel no longer fires when scrolled into view, so no additional rows ever load.
The user sees only the first 20 rows regardless of how far they scroll,
with the "Loading..." sentinel sitting at the bottom doing nothing.
The correct trigger is hx-trigger="revealed", which fires when the element enters the visible area.
The /rows route below always appends a sentinel, even when it fetches the last rows. What does the user see, and what extra work does the server do?
The sentinel appears after the last real row.
When the user scrolls to it, the browser sends one more request to /rows?offset=....
The server queries the database, finds zero rows, and returns an empty string.
HTMX replaces the sentinel with the empty string, which removes it.
The user sees the sentinel flicker briefly; the server does one unnecessary database query per table load.
The fix is to only append the sentinel when len(rows) == PAGE_SIZE.
result = "".join(str(make_row(row)) for row in rows)
result += str(make_sentinel(offset + PAGE_SIZE))
return result
Change hx-swap="innerHTML" on each data row to hx-swap="outerHTML". What goes wrong after the first click?
outerHTML replaces the entire #detail element, not just its contents.
After the first click, the <div id="detail"> no longer exists in the page.
Subsequent clicks still send a request,
but HTMX cannot find a #detail element to insert into,
so nothing appears.
innerHTML is correct because it leaves the target element in place and only changes what is inside it.
The detail pane shows "Sighting not found." even though the record definitely exists. What are two likely causes?
Either the row's hx-get attribute contains the wrong sighting ID
(perhaps row["id"] was formatted incorrectly),
or the /sighting/{sighting_id}/detail route is looking in a different database file
than the one populated with data.
Check the URL by inspecting the element in the browser's developer tools,
then verify that DB_PATH in the server points to the same database the other lessons use.
Why does tr[hx-target]:hover in the CSS correctly highlight only the clickable rows in server_detail.py but have no effect in server_scroll.py?
tr[hx-target] is a CSS attribute selector that matches <tr> elements which have an hx-target attribute.
In server_detail.py, each data row carries hx-target="#detail", so the selector matches them.
In server_scroll.py, make_row adds no HTMX attributes to data rows at all,
so no <tr> has hx-target and the rule matches nothing.
Exercises
Replace the Sentinel with a Button
Change the sentinel row to a "Load more" button that the user must click to fetch the next page.
Replace hx-trigger="revealed" with hx-trigger="click" and style the button to span all eight columns.
Compare the user experience with and without the automatic trigger.
Show a Loading Indicator
Add an hx-indicator attribute to the sentinel row pointing to a <span> element that shows
the text "Loading..." and is hidden by default using the CSS class htmx-indicator.
HTMX adds this class to the indicator while a request is in flight and removes it when the response arrives.
Confirm that the indicator appears briefly as each page loads.
Highlight the Selected Row
When the user clicks a row to load its detail, give that row a visual marker so they can see
which record is currently displayed.
Add a selected CSS class with a distinct background color and use the hx-on:htmx:after-swap
attribute on #detail to remove the class from any previously selected row and add it to the new one.
Filter by Species
Add a <select> element above the table listing the two species.
Use hx-get="/?species=..." and hx-trigger="change" on the select to reload just the <tbody>
when the user picks a different species.
Update the index route to accept an optional species query parameter
and filter the database query accordingly.