Dynamic Pages

Goals

What Is HTMX?

What problem does HTMX solve, and what would the same interaction look like without it?

How does HTMX get loaded, and what does a minimal HTMX attribute look like?

<script src="https://unpkg.com/htmx.org@2.0.4"></script>

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.

What does the /rows route return, and how does it know when to stop adding sentinels?

i
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..."]]
i
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 "",
                            ],
                        ],
                    ],
                ],
            ]
        )
i
    @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()
i
litestar run --app server_detail:app

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.

Write the server route that returns the detail fragment.

i
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:]],
    ]
i
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."]
                    ],
                ],
            ]
        )
i
    @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.