htmx: Build Interactive Web Apps with HTML · Production Concerns
URLs, History and hx-boost
Make htmx pages shareable and reload-safe: push URLs, boost navigation, return full pages on direct visits, and set Vary for caches.
What you'll build
Contact detail navigation where clicking a contact loads it into the page and changes the URL to
/contacts/2. Reloading or sharing that URL shows the same contact, and the Back button works.
The idea
hx-push-url="true"puts the request URL in the address bar and adds a history entry.hx-boost="true"turns normal links and forms into htmx requests that swap the<body>and push the URL. Links still work without JavaScript.- The golden rule: every URL you push must also work as a normal page load. A reload, a shared link, a
history-cache miss or a search engine requests the URL directly. Check
HX-Requestto return a fragment to htmx and a full page otherwise, and sendVary: HX-Request. - htmx 2 restores a history-cache miss with
HX-Request: trueandHX-History-Restore-Request: true; treat the second header as "send the full page".
The code
<nav hx-target="#detail" hx-push-url="true">
<button hx-get="/contacts/1">Ada</button>
<button hx-get="/contacts/2">Grace</button>
</nav>
<section id="detail">Choose a contact.</section>
<button id="direct">What would a direct visit to /contacts/2 get?</button>
<pre id="raw" style="white-space:pre-wrap;font-size:12px"></pre>
<script>
// A reload or shared link is a plain request without htmx's headers. Imitate one:
document.getElementById("direct").addEventListener("click", () => {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/contacts/2");
xhr.onload = () => { document.getElementById("raw").textContent = xhr.responseText; };
xhr.send();
});
</script>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
const people = { 1: ["Ada Lovelace", "Mathematician"], 2: ["Grace Hopper", "Computer scientist"] };
server.get("/contacts/:id", (req) => {
const [name, role] = people[req.params.id];
const fragment = `<h3>${name}</h3><p>${role}</p>`;
const isHtmx = req.headers["hx-request"] === "true" && !req.headers["hx-history-restore-request"];
return { headers: { Vary: "HX-Request" },
body: isHtmx ? fragment : `<!doctype html><html><body><nav>...</nav><section id="detail">${fragment}</section></body></html>` };
});
</script>
(The last button sends a plain request without htmx's headers, like a reload or a shared link, and shows the response as text: the full page.)
Common mistakes
- Pushing URLs that return only a fragment: reloading shows an unstyled scrap of HTML.
- Missing
Vary: HX-Request: a browser or CDN cache serves the fragment to a normal visit, or the full page to htmx. hx-push-urlon every small interaction (tabs, filters as you type): usehx-replace-urlor no URL change.- Boosting a page whose scripts expect a fresh page load each time: initialise widgets on
htmx:loadinstead.
Key points
- Pushed URLs must render as full pages on direct requests.
- Decide by
HX-Request(andHX-History-Restore-Request), and sendVary: HX-Request. hx-boostis progressive enhancement for normal links and forms.
Further reading: hx-push-url · hx-boost · htmx docs: history · MDN: Vary
Practice questions
A user reloads /contacts/2 after htmx pushed it, and sees an unstyled fragment. What's wrong?
Answer
The server returns the fragment for every request. It must return the full page when HX-Request is absent (or HX-History-Restore-Request is present).
Why send Vary: HX-Request?
Answer
The same URL returns different bodies depending on that header; Vary tells caches to store them separately.
What does hx-boost do, and why is it progressive enhancement?
Answer
It turns normal links and forms into htmx requests that swap the body and push the URL; without JavaScript they still work as normal links and forms.
Open in the app to mark lessons complete, take notes and track your progress.