Skip to content
iD-SIGN
Contact us

Navigation in Symfony with htmx

Navigation without full page reloads in Symfony with htmx 4: boost and morph, prefetch, View Transitions, error pages, the back button with scroll restoration, lazy loading and SEO.

part 1/3 16 min readpublished 11/2026

In this article 12 chapters
  1. Principles
  2. Installation
  3. Navigation
  4. Prefetch
  5. View Transitions
  6. Error pages
  7. The back button and scrolling
  8. Lazy loading
  9. SEO
  10. Analytics
  11. Testing and deployment
  12. Checklist

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 :inherited suffix 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 example htmx: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

bash
npm install htmx.org@4

The entry file is the same for Vite and Webpack Encore:

javascript
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.

Basic setup

Most websites need just one setting on <body>:

xml
<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:

xml
<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-head only adds to the head and titles and meta tags pile up.
  • Give the target as a selector, not target:this.
  • Do not put hx-target or hx-select on #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:

xml
<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 id or with a shared id carries state between unrelated elements. Text from a search field shows up in an email field on the next page, a password becomes readable in a text field (morph changes type but keeps the value) and a listener attached directly to a “Delete” button fires after clicking the new “Share” button.
  • The same inner id under different containers. When an element inside the container has the same id on both pages, morph carries it, state included, into the new container and the component in it can lose its state. Choose ids 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-initialized set in the browser disappears and the widget initializes a second time. Add it to htmx.config.morphIgnore (extend the default array, do not overwrite it), or check the state through the library, for example Chart.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 @click from 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 switcher hx-boost="false", or set lang from the response:
javascript
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:true in hx-swap leaves the title to hx-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.

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:

javascript
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:

xml
<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 id together 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 example mousedown 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:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • With prefers-reduced-motion do 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-name is 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 dev and test environments they are not rendered, because kernel.debug is on. Test them on a prod build.
  • With the #app variant, a response without #app would 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:
javascript
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:

php
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; both HX-Request and HX-Boosted are 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-url must 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:swap and create them in htmx.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-cache extension, which stores pages in sessionStorage, 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:

javascript
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:

xml
<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>
php
#[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-trigger must 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), intersect with 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:swap fires 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:
javascript
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.

bash
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 follow hx-get on 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, canonical and hreflang are in the response of every URL, and hx-head then 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: noindex and are not in the sitemap. Do not block them in robots.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-head merged 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:request and attach the listener in the same script before the click. Waiting for the .htmx-request class 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-head and hx-preload imported after the core, scripts in <head>, one-off ones with hx-preserve="true"
  • <body> with hx-boost:inherited and morph, or #app with hx-head="merge" and hx-history-elt
  • Main container with an id based on the route, inner ids 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 languages
  • AuthenticationEntryPoint returns HX-Redirect for htmx if the login page has a different layout or fragments are behind login
  • Error pages with the full layout, with #app a safeguard against an empty selection
  • Shortened View Transitions, off with prefers-reduced-motion, no global scroll-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 in morphIgnore
  • SEO: curl returns the full page, links are <a href>, main content in the first HTML, fragments with noindex
  • 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

Tags Backend, PHP, JavaScript

Share LinkedIn Facebook

Need more than an article?

A few sentences about what you are dealing with are enough.