htmx: Build Interactive Web Apps with HTML · Build a Contacts App
Paging and Infinite Scroll
Load the contact list one page at a time, first with a Load more button, then automatically as the user scrolls.
What you'll build
Paging for a long contact list: the first 5 contacts render with the page, and the next 5 load when the user presses Load more or scrolls to the end, until there are no more.
The idea
The last row of each page is a "loader" row that requests the next page and replaces itself
(hx-target="this" + hx-swap="outerHTML"). The response contains the next rows plus a new loader row, or a final
"end of list" row. Switch the trigger from click to revealed for infinite scroll (or intersect once inside a
scrolling container).
Use keyset pagination on the server for large tables: "the next 5 after id 25" (WHERE id > 25 ORDER BY id LIMIT 5)
stays fast and never skips or repeats rows when new contacts are added, unlike OFFSET.
The code
<div style="max-height:200px;overflow:auto;border:1px solid #ccc">
<table><tbody>
<tr><td>Contact 1</td></tr><tr><td>Contact 2</td></tr><tr><td>Contact 3</td></tr>
<tr><td>Contact 4</td></tr><tr><td>Contact 5</td></tr>
<tr hx-get="/contacts?after=5" hx-trigger="click, intersect once" hx-target="this" hx-swap="outerHTML">
<td><button>Load more</button></td></tr>
</tbody></table>
</div>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
const TOTAL = 23;
server.get("/contacts", (req) => {
const after = Number(req.query.after);
let html = "";
for (let id = after + 1; id <= Math.min(after + 5, TOTAL); id++) html += `<tr><td>Contact ${id}</td></tr>`;
const last = Math.min(after + 5, TOTAL);
html += last < TOTAL
? `<tr hx-get="/contacts?after=${last}" hx-trigger="click, intersect once" hx-target="this" hx-swap="outerHTML">
<td><button>Load more</button></td></tr>`
: `<tr><td><em>All ${TOTAL} contacts loaded.</em></td></tr>`;
return html;
}, { delay: 500 });
</script>
Keeping a real button inside the loader row means keyboard users and users who can't scroll can still load more.
Common mistakes
- The loader row targets the
<tbody>withinnerHTML: each page replaces the previous one. - Forgetting
onceonintersect, or keeping the trigger on an element that stays visible: duplicate requests. OFFSETpaging on a busy table: rows shift between requests, so users see duplicates or miss rows.- Infinite scroll with a footer: users can never reach it. Use Load more where the footer matters.
Key points
- The loader replaces itself with the next page plus a new loader.
revealedorintersect oncemake it automatic; a button keeps it accessible.- Keyset pagination keeps pages stable and fast.
Further reading: htmx example: infinite scroll · htmx example: click to load
Practice questions
How does the 'loader row' pattern avoid duplicating or losing rows?
Answer
It targets itself with outerHTML, so each response replaces only the loader with the next rows and a new loader.
Why prefer keyset pagination (WHERE id > last) over OFFSET for infinite scroll?
Answer
It stays fast on large tables and doesn't skip or repeat rows when rows are inserted or deleted between requests.
When should you avoid infinite scroll?
Answer
When users need the footer, need to return to a position, or must reach specific items; offer Load more or numbered pages instead.
Open in the app to mark lessons complete, take notes and track your progress.