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.
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"withhx-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,
/contactsreturns the full page for normal visits and only the rows for htmx requests (see the server-side lesson).
The code
<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-urlon a search box: every pause adds a history entry and the Back button becomes useless. Usehx-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.