The first part of the series is about navigation: how to turn a Symfony website into pages you move between without a full page reload, and what to watch out for. Forms are covered in the second part, Alpine.js in the third. It applies to htmx 4.
Principles
The server returns finished HTML and htmx inserts it into the page. There is no need for a frontend API or a second layer of state and validation; templates, forms and security stay in Symfony.
Every interaction goes the same way: trigger → request → response → selecting part of the response (hx-select) → swap into the DOM → settle → history entry. Attributes decide what happens at each step.
Good to know up front:
- Inheritance is explicit. Only an attribute with the
:inheritedsuffix is passed on to descendants. Without it, links are not boosted and no error appears. - Extensions are activated by importing them, no attribute is needed.
- Events have the form
htmx:phase:action, for examplehtmx:before:swap. A listener with a typo in the name silently never fires. - Everything is swapped except 204 and 304, including error pages.
- History does not store copies of pages. The back button downloads the page from the server again.
- Morph is built into the core.
In this series we only list attributes, events and headers where a specific situation needs them. All of them are described in the htmx 4 reference.
Installation
npm install htmx.org@4
The entry file is the same for Vite and Webpack Encore:
import htmx from 'htmx.org'
// Extensions register on the global htmx, so they come after the core.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'
htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches
registerScrollRestore()
- You do not need to call
htmx.initialize(), htmx starts on its own once the module is evaluated. Register extensions and listeners right in the module. - Scripts belong in
<head>. When the content of<body>is morphed, a script at the end of the page would behave like an ordinary element.
Navigation
Basic setup
Most websites need just one setting on <body>:
<body hx-boost:inherited="true"
hx-swap:inherited="innerMorph"
hx-head:inherited="merge">
Controllers do not change and return full pages. Search with filters and pagination can run the same way, as ordinary GET forms and links, without fragments and without reading headers on the server.
hx-swap:inherited is also inherited by elements with their own hx-get or hx-post, so they should have their own hx-swap.
When something on the page must stay outside the swap, for example elements inserted by a third-party script, swap only the main container:
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
hx-head="merge"is required here. When the target is not<body>,hx-headonly adds to the head and titles and meta tags pile up.- Give the target as a selector, not
target:this. - Do not put
hx-targetorhx-selecton#app, all descendants would inherit them. - Without
hx-history-elt, the back button would replace the whole<body>.
What to boost and what not
Boost applies to links and forms on the same origin. It skips links with download, with a target other than _self and links starting with #. A click with Ctrl, Cmd or Shift is left to the browser.
hx-boost="false" belongs on:
- links to the admin or another part with its own assets,
- pages with a completely different layout, for example a login page without header and footer,
- the login form and links that change something (logout via GET),
- a toggle that handles state only in the browser.
Morphing between unrelated layouts is an unnecessary risk, a full page load is more reliable.
Morph and element identity
Morph pairs elements by tag and id. An element without an id is merged with any element with the same tag in the same place. An element with an id is merged only with an element with the same id, otherwise it is inserted as new and the old one disappears.
A proven approach is to give the main content container an id based on the route:
<main id="content-{{ app.request.attributes.get('_route')|default('page') }}">
{% block content %}{% endblock %}
</main>
The same route is morphed and its state stays, a different route replaces the content cleanly. Navigation, sidebar and footer stay outside the container and their state survives the transition. Watch out for:
- A container without an
idor with a sharedidcarries state between unrelated elements. Text from asearchfield shows up in anemailfield on the next page, a password becomes readable in a text field (morph changestypebut keeps the value) and a listener attached directly to a “Delete” button fires after clicking the new “Share” button. - The same inner
idunder different containers. When an element inside the container has the sameidon both pages, morph carries it, state included, into the new container and the component in it can lose its state. Chooseids inside the page so they do not repeat across different pages, unless the element is meant to survive the transition. - Forms should have their own
id, for the login form it is a must. - Attach listeners via delegation on
document, or remove them before the swap. - Morph removes attributes that are not in the response. A flag like
data-map-initializedset in the browser disappears and the widget initializes a second time. Add it tohtmx.config.morphIgnore(extend the default array, do not overwrite it), or check the state through the library, for exampleChart.getChart(canvas). - Morph keeps field values, so it is not suitable for resetting a form (see part 2).
- Attribute names must pass through
setAttribute(). Morph copies attributes with it and WebKit rejects a name the HTML parser accepts, for example@clickfrom Alpine.js. Morph then stops halfway without an error in the console. Details are in the third part.
You exclude an element from morph with hx-morph-skip; hx-morph-skip-children updates only its attributes.
Head, title and language
The hx-head extension merges <head> by exact tag match. The title, description, canonical and JSON-LD are replaced after the transition.
- Mark analytics and other one-off scripts in the head with
hx-preserve="true", otherwise they would be removed and run again on every transition. <html lang>does not change, because htmx swaps only the content of<body>. After switching languages, the old language would remain for screen readers and translators. Give the language switcherhx-boost="false", or setlangfrom the response:
document.addEventListener('htmx:before:swap', (event) => {
const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
if (lang) document.documentElement.lang = lang
})
- htmx writes the title only after the swap, and with View Transitions enabled only after the transition finishes. A quick second click can then leave the first page's title in the tab.
ignoreTitle:trueinhx-swapleaves the title tohx-head, which sets it immediately. - Do not load CSS from lazily imported JavaScript. Vite inserts it into
<head>, the next transition removes it as unknown and it is not added again. Lazy load only JavaScript.
Menus after a transition
A dropdown menu on :hover and :focus-within stays open after clicking a link in it, because the link keeps focus after the transition. With a full page load focus is lost on its own; with htmx you need to remove it:
document.addEventListener('htmx:before:request', (event) => {
if (event.detail.ctx.request.headers['HX-Boosted']) document.activeElement?.blur()
})
Prefetch
The hx-preload extension downloads the page as early as mousedown or touchstart. By the time the user releases the button, the content is on its way or ready. For boosted links it is enough to load the extension, the attribute is only needed to change the behavior:
<body hx-preload:inherited="mousedown, touchstart">
Prefetch is a real request and does not use the HTTP cache, so you do not need to set Cache-Control for it. It applies only to GET.
Exclude actions that change something from prefetch. Prefetch does not distinguish mouse buttons and also fires on a right click, a middle click or the start of a scroll on mobile. A GET link would then perform an action the user did not want:
- logout,
- adding to the cart or to favorites,
- deleting, archiving, marking as read,
- voting, confirming or cancelling a subscription,
- switching language or currency stored in the session,
- an export that is expensive to generate on the server,
- anything that consumes a flash message.
The cleanest way is to do such actions with a POST form with a CSRF token, prefetch does not apply to POST. If it has to stay a link, exclude it: hx-preload="none" turns off only prefetch and keeps boost, hx-boost="false" turns off both. Logout and language switching rather belong with hx-boost="false", because they change the whole page.
Further consequences:
- View counters slightly overcount, a press the user changed their mind about is counted too. Do not filter prefetch on the server: after the click it is often the only request, so almost all real views would disappear. On a list where a scroll gesture often starts on a link, narrow the triggers with
hx-preload:inherited="mousedown". - Do not extend the default 5 second validity where there is personal state. Morph keeps a link without an
idtogether with its downloaded response, so a longer validity would show an old balance after a purchase. On a purely content website it can be extended, for examplemousedown timeout:30s. - Prefetch on hover does not pay off. The extension has no delay, so moving across a menu renders every page behind it. A custom delay led to duplicate requests and gained only tens of milliseconds over
mousedown.
View Transitions
htmx.config.transitions = true turns on View Transitions. With the default duration a transition takes about 300 ms, which on a website with prefetch feels slower than the website is. Shorten all three pseudo-elements, the length of the whole transition is set by group:
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 100ms;
}
- With
prefers-reduced-motiondo not turn transitions on at all. The snapshot alone freezes the page for the duration of the transition, even when the animation is disabled in CSS. - An element with its own
view-transition-nameis animated separately. The underline of the active menu item as a separate<span>with its own name thus slides smoothly between pages, including a change of width. The name must be unique in the document, two identical names cancel the transition. - Do not use a global
scroll-behavior: smooth. The page would scroll smoothly after every transition and every step back. Turn on smooth scroll only for specific links.
Error pages
htmx swaps an error response too, so a boosted link to a non-existent page shows your 404 page.
- Templates in
templates/bundles/TwigBundle/Exception/must have the full layout. - In the
devandtestenvironments they are not rendered, becausekernel.debugis on. Test them on aprodbuild. - With the
#appvariant, a response without#appwould delete the content and leave an empty page. It happens with a 502 from the proxy during a deployment, a PHP fatal error or Symfony's fallback error page. A safeguard hands the navigation over to the browser:
document.addEventListener('htmx:before:swap', (event) => {
const { ctx, tasks } = event.detail
if (ctx.target?.id !== 'app') return
const main = tasks.find((task) => task.type === 'main')
if (main && main.fragment.childElementCount === 0) {
event.preventDefault()
window.location.assign(ctx.request.action)
}
})
Expired session
When the session expires, Symfony redirects to the login page and fetch follows the redirect on its own. For a boosted link with a login page in the same layout this works without changes. When the login page has a different layout or a fragment with its own target is being loaded, the login page would be inserted in the wrong place. It helps when AuthenticationEntryPoint forces a full page load for htmx:
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$loginUrl = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
}
return new RedirectResponse($loginUrl);
}
Before this call Symfony stores the request URL as the target after login. For a fragment requested via GET, the user would land on a bare fragment after logging in, so remove the target in this branch with TargetPathTrait::removeTargetPath().
The back button and scrolling
htmx does not keep a copy of the page it leaves, so the back button downloads it from the server again. The browser's back/forward cache does not help, it works only when moving between documents.
- In a browser with the Navigation API, htmx intercepts the navigation, downloads the page and swaps it. In Firefox and browsers without this API, the same work is triggered by
popstate. - The request carries only
HX-History-Restore-Request; bothHX-RequestandHX-Boostedare missing. The server therefore returns the full page unchanged. - The swap uses
outerSync, not morph. Component state and half-filled fields are lost.
This means:
- State that should survive the back button belongs in the URL. Filter, page and search must be in the address. A fragment with
hx-push-urlmust have an address at which the server returns the full page. - Widgets must be able to tear down and set up again. Destroy charts in
htmx:before:swapand create them inhtmx.onLoad. - Cleaning up state outside the swap also belongs in
htmx:before:swap, for example removing the class that locks scrolling while the menu is open. - Without a request to the server, the back button works with the
hx-history-cacheextension, which stores pages insessionStorage, so Back sends no request. The price is that the data may not be fresh after returning.
Scroll restoration
The browser restores the scroll position only after the intercepted navigation finishes, so the page first renders at the top and then jumps down. Firefox does the opposite: it still scrolls the old content, and when going from a longer page to a shorter one the position is clipped after the swap. The solution is to remember the position by the history entry key:
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50
function load() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
} catch (e) {
return {}
}
}
function store(positions) {
const keys = Object.keys(positions)
for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} catch (e) { /* private window: scroll is restored only until reload */ }
}
export default function registerScrollRestore() {
if (!window.navigation) return
const positions = load()
let pending = null
// The same condition under which htmx listens to popstate instead of intercepting navigation.
if (/firefox/i.test(navigator.userAgent)) {
history.scrollRestoration = 'manual'
const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
const target = positions[navigation.currentEntry.key]
if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)
// Leaving via the address bar does not fire the navigate event.
window.addEventListener('pagehide', () => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
})
}
// Must be registered before the htmx listener, otherwise scroll: 'manual' comes too late.
navigation.addEventListener('navigate', (event) => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange
// Entries with the same URL are added by a modal window: the page does not change and no swap comes.
if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
event.intercept({ scroll: 'manual' })
return
}
const target = positions[event.destination.key]
if (!isHistoryRestore || target === undefined) return
pending = target
event.intercept({ scroll: 'manual' })
})
const restore = () => {
if (pending === null) return
window.scrollTo(0, pending)
pending = null
}
// With a View Transition, htmx:after:swap comes only after the transition. Scrolling therefore
// belongs at the end of its task, so the new snapshot is already scrolled correctly.
document.addEventListener('htmx:before:viewTransition', (event) => {
if (pending === null) return
const task = event.detail.task
event.detail.task = async () => {
await task()
restore()
}
})
document.addEventListener('htmx:after:swap', restore)
}
The module skips browsers without the Navigation API; there the browser restores the scroll position itself, jump included. Modal windows tied to history are covered in the third part.
Lazy loading
It pays off for content that is large or expensive to prepare and that most people never open, for example a price history chart, an order history or a document preview in a collapsed section:
<details>
<summary>Price history</summary>
<div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
hx-trigger="toggle once from:'closest details'"
hx-swap="innerHTML"></div>
</details>
#[Route('/product/{id}/price-history', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
$response = $this->render('product/_price_history.html.twig', ['product' => $product]);
$response->setPublic();
$response->setMaxAge(3600);
$response->headers->set('X-Robots-Tag', 'noindex');
return $response;
}
- A multi-word selector in
hx-triggermust be in quotes. htmx does not support parentheses and a parsing error stops processing of the rest of the page. - Other useful triggers:
load(right after insertion),revealed(when the element enters the viewport),intersectwith a threshold. - When a value from the URL determines a template or file, restrict it with
requirements. - Reserve space for the content so the page does not shift after loading.
- A fragment without personal data can be publicly cached.
JavaScript for new content
- Initialize widgets in
htmx.onLoad(callback). It runs on the first load and after every swap, including the back button. Initialization must be repeatable. htmx:after:swapfires on the element that sent the request, not on the target. It is not suitable for initializing new content.- Load heavy libraries (code highlighting, maps, charts) with
import()only when their element is on the page. Only the condition stays in the main bundle:
function highlightCode(root = document) {
if (!root.querySelector?.('pre code:not([data-highlighted])')) return
import('./js/code-highlight.js').then(({ default: highlight }) => highlight(root))
}
highlightCode()
htmx.onLoad(highlightCode)
SEO
htmx is invisible to search engines as long as one rule holds: every URL returns the full page without JavaScript. A crawler does not send HX-Request, so it gets the same as a user on the first load.
curl -s https://example.com/services | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/does-not-exist | head -1
- Links are
<a href>. A crawler does not followhx-geton a button or a<div>. - Whatever should be found is in the first HTML. A crawler does not click, scroll or open a section. Use lazy loading only for extras. Keep text that should be findable in the HTML, even in a collapsed
<details>. Many AI crawlers do not run JavaScript at all. - Title, description,
canonicalandhreflangare in the response of every URL, andhx-headthen replaces them for the user too. - Real status codes. A 404 must be a 404, a move a 301. An error page with status 200 would end up in the index.
- Fragments do not belong in the index. Their own routes get
X-Robots-Tag: noindexand are not in the sitemap. Do not block them inrobots.txt, the crawler would not read the header. - Core Web Vitals depend mainly on the first load, not on transitions. Watch the bundle size and server response time, because prefetch and the back button go through the server.
Pagination and filters from an SEO point of view are covered in part 2.
Analytics
A transition via htmx is not a page load, so analytics does not count the view on its own.
- Send your own pageview after
htmx:after:swap, only when the URL has changed. An opened modal or a submitted form is then not counted. - The title is already new at that point,
hx-headmerged it when the response arrived. Events after the history entry is written arrive before the swap, while the page still shows the old content. - Google Tag Manager handles transitions with the History Change trigger. With your own sending on top, you get two views.
- The back button counts as a view.
- Mark the analytics script with
hx-preserve="true".
Testing and deployment
- Before upgrading htmx, try a transition and a form submission in an older browser as well, and go through the occurrences of
htmx:in templates. - In end-to-end tests, wait for
htmx:finally:requestand attach the listener in the same script before the click. Waiting for the.htmx-requestclass to disappear right after the click can finish before the class even appears. - Test the back button in Chrome and in Firefox, it runs through different code there. Test with View Transitions and with
prefers-reduced-motion. - Test transitions in WebKit too. A morph error may not log anything there; you recognize it by part of the old page remaining after a click.
- Test error pages on
prod, and once also an outage without your template, for example a 502 from the proxy.
Checklist
hx-headandhx-preloadimported after the core, scripts in<head>, one-off ones withhx-preserve="true"<body>withhx-boost:inheritedand morph, or#appwithhx-head="merge"andhx-history-elt- Main container with an
idbased on the route, innerids do not repeat across pages hx-boost="false"on logout, login, admin and other layouts- Actions that change something (cart, deleting, voting…) via POST, otherwise
hx-preload="none" <html lang>matches the page after switching languagesAuthenticationEntryPointreturnsHX-Redirectfor htmx if the login page has a different layout or fragments are behind login- Error pages with the full layout, with
#appa safeguard against an empty selection - Shortened View Transitions, off with
prefers-reduced-motion, no globalscroll-behavior: smooth - Scroll restoration module registered before htmx starts
- State that should survive Back is in the URL
- Widgets in
htmx.onLoad, repeatable initialization, client-side flags inmorphIgnore - SEO:
curlreturns the full page, links are<a href>, main content in the first HTML, fragments withnoindex - Pageview after
htmx:after:swap, not after the history change - Back button tested in Chrome and Firefox, transitions also in WebKit, error pages on
prod