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.

Create a free account to save this page, take notes and track your progress. Sign in

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

Load more, then infinite scrollTry it yourself
<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> with innerHTML: each page replaces the previous one.
  • Forgetting once on intersect, or keeping the trigger on an element that stays visible: duplicate requests.
  • OFFSET paging 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.
  • revealed or intersect once make 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.

Was this page helpful?