htmx: Build Interactive Web Apps with HTML · Build a Contacts App

The Contact List and Active Search

Build a contact table that filters as the user types, keeps the search in the URL and never shows stale results.

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

What you'll build

The first screen of the contacts app: a table of contacts with a search box. Results update as you type, the URL reflects the search (so it can be bookmarked), slow responses can't overwrite newer ones, and an empty result shows a helpful message.

The idea

  • hx-get="/contacts" with hx-trigger="input changed delay:300ms, search" searches after a pause, and when the search box's clear button is used.
  • hx-target="#rows" swaps only the <tbody>; the table header stays.
  • hx-replace-url="true" updates the address bar (/contacts?q=ada) without adding a history entry per keystroke.
  • hx-sync="this:replace" aborts the previous request when a new one starts.
  • On a real server, /contacts returns the full page for normal visits and only the rows for htmx requests (see the server-side lesson).

The code

Contacts with active searchTry it yourself
<input type="search" name="q" placeholder="Search contacts..." aria-label="Search contacts"
       hx-get="/contacts" hx-trigger="input changed delay:300ms, search" hx-target="#rows"
       hx-replace-url="true" hx-sync="this:replace" hx-indicator="#spin">
<span id="spin" class="htmx-indicator">Searching...</span>
<table>
  <thead><tr><th>Name</th><th>Email</th></tr></thead>
  <tbody id="rows" aria-live="polite">
    <tr><td>Ada Lovelace</td><td>[email protected]</td></tr>
    <tr><td>Alan Turing</td><td>[email protected]</td></tr>
    <tr><td>Grace Hopper</td><td>[email protected]</td></tr>
    <tr><td>Katherine Johnson</td><td>[email protected]</td></tr>
  </tbody>
</table>

<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
  const contacts = [["Ada Lovelace", "[email protected]"], ["Alan Turing", "[email protected]"],
                    ["Grace Hopper", "[email protected]"], ["Katherine Johnson", "[email protected]"]];
  server.get("/contacts", (req) => {
    const q = (req.query.q || "").trim().toLowerCase();
    const found = contacts.filter(([n, e]) => (n + " " + e).toLowerCase().includes(q));
    if (!found.length) return `<tr><td colspan="2">No contacts match "${server.escape(q)}".</td></tr>`;
    return found.map(([n, e]) => `<tr><td>${server.escape(n)}</td><td>${server.escape(e)}</td></tr>`).join("");
  }, { delay: 350 });
</script>

On the server the search is an ordinary query with a bound parameter:

rows = db.execute("SELECT name, email FROM contacts WHERE name ILIKE %s OR email ILIKE %s ORDER BY name LIMIT 50",
                  (f"%{q}%", f"%{q}%")).fetchall()

Common mistakes

  • hx-push-url on a search box: every pause adds a history entry and the Back button becomes useless. Use hx-replace-url.
  • No hx-sync: a slow response for "a" can arrive after the response for "ada" and show the wrong rows.
  • Building SQL from the search text with string formatting (SQL injection). Always bind parameters.
  • No LIMIT: a one-letter search can return the whole table.

Key points

  • Debounce, replace stale requests, and swap only the rows.
  • Keep the search in the URL with hx-replace-url.
  • The search endpoint is a plain, parameterised query that returns rows.

Further reading: htmx example: active search · hx-sync · hx-replace-url

Practice questions

Why use hx-replace-url instead of hx-push-url for a live search box?

Answer

replace updates the URL without adding a history entry for every pause, so Back still goes to the previous page.

What problem does hx-sync="this:replace" solve for active search?

Answer

Out-of-order responses: it aborts the in-flight request when a new one starts, so an old, slow response can't overwrite newer results.

What should the search response contain when the table header stays on the page?

Answer

Only the <tr> rows for the <tbody> target (or an empty-state row), not the whole table or page.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?