Building Live Search with htmx: hx-get and hx-target in Practice
Use htmx's hx-get and hx-target to build live search with server-rendered HTML fragments—no client-side framework required. Includes a worked Flask example, trade-offs, and a verification checklist.
04 Sept 2025, 14:29 UTC

The Problem: Search Without the Spinner
You need a search box that shows results as the user types. The classic approach means wiring up fetch, managing loading states, and manually patching the DOM. htmx's hx-get and hx-target let you declare that behavior in HTML, pushing the rendering logic to the server where your templates already live.
How the Attributes Work Together
hx-get tells htmx to issue a GET request to the specified URL when the element's trigger fires. hx-target uses a CSS selector to decide which element receives the response HTML. By default the response replaces the target's inner HTML, but you can change that with hx-swap.
A minimal pattern looks like this:
<input type="search" name="q" hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:150ms">
<div id="results"></div>
The hx-trigger value keyup changed delay:150ms means: fire on keyup, but only if the value actually changed, and debounce by 150 ms. This keeps request volume reasonable without extra JavaScript.
Worked Example: Server-Side Template Fragment
Assume a Python Flask backend, but the same idea applies to Django, Node, Go, or any template engine.
# app.py
from flask import Flask, request, render_template_string
app = Flask(__name__)
ITEMS = ["apple", "banana", "cherry", "date", "elderberry"]
@app.route("/search")
def search():
q = request.args.get("q", "").lower()
matches = [item for item in ITEMS if q in item]
return render_template_string("""
{% for item in matches %}
<div class="result-item">{{ item }}</div>
{% else %}
<div class="result-empty">No matches</div>
{% endfor %}
""", matches=matches)
if __name__ == "__main__":
app.run(port=5000)
The endpoint returns a plain HTML fragment—no JSON wrapper. The browser inserts that fragment into #results automatically.
Frontend page (served from the same origin):
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<input type="search" name="q" placeholder="Type to search…"
hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:150ms">
<div id="results" class="results-container"></div>
</body>
</html>
Run the server, open the page, and type "a". The network tab shows a GET to /search?q=a returning two <div> elements, which appear instantly under the input.
Trade-offs and Limitations
- GET only.
hx-getcannot send a request body. For mutations (create, update, delete) usehx-post,hx-put, orhx-deletewith a form orhx-include. - CORS still applies. If your API lives on a different origin, the server must return
Access-Control-Allow-Originheaders. htmx does not bypass browser policy. - Template coupling. The backend must emit HTML that matches the target's expected structure. A change to the fragment's root element can break the swap unless you adjust
hx-swap(e.g.,outerHTML). - No built-in loading indicator. You can add one with
hx-indicatorpointing to a hidden spinner, but it's not automatic.
Verification Checklist
- Open the browser dev-tools Network tab. Confirm the request is a GET and the response is
text/html. - Inspect the
#resultselement after typing. The fragment should be direct children, not wrapped in an extra container. - Test with an empty query—ensure the "No matches" branch renders.
- Simulate a slow network (throttling) to verify the debounce prevents request storms.
Next Steps
Start by extracting one read-only UI piece—search suggestions, a filter sidebar, a pagination control—into a fragment endpoint. Replace the hand-rolled fetch logic with hx-get/hx-target. Measure the lines of client-side code you delete; that's usually the clearest signal the pattern fits.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.