12min.

Making a Single-Page Application with HTMX and Symfony

With the plethora of Javascript frameworks available today, web pages became smoother and smoother in terms of User Experience, a field Symfony has been trying to improve too with initiatives like Symfony UX.

That’s great for users, as it provides them with a seamless and intuitive experience, but it does mean that we have to change our way of making websites, in brand-new ways, every day. Solutions such as Symfony UX started to see the light of day to simplify the development of UX oriented components / Website. We covered this one specific tool in another blog post before, and it works great! However, just because we use Symfony, it does not make it our only option!

This article is about a library that has recently been making waves in the realm of UX-enhancing libraries, htmx.

Section intitulée htmxHTMX

HTMX logo

htmx is a library that allows you to access modern browser features directly from HTML, rather than using javascript.

— htmx docs

This library was born on the realization that hypermedia, our main door into web development via HTTP and HTML, hasn’t evolved in 20 years! Using HTML is mandatory, but feels like driving an old car: sure it drives, but there’s so much more it could do!

htmx allows any DOM element to trigger any event from any interaction, and to make a request using any HTTP method, without reloading the entire page, without a single line of javascript.

It is used a lot in the Django community, a Python framework, but it is not tied to any sort of back-end. It means that we are free to couple it to a Symfony app to create a great UX, and even create a SPA (sort of) with it.

The Single Page Application approach has really been put forward in recent years. It offers a more interactive and immersive experience than what old hypermedia-based applications could ever offer. But it comes with a cost, as it requires a lot of Javascript to work, a lot of abstraction, and it is not always the best solution for every project / team size / budget.

Well, most of what SPA offers is possible using htmx, while still keeping a hypermedia-friendly approach. That means no JSON API to develop, no implicit contract between the frontend and the backend, no duplication of the application state between frontend and backend, and so on! 😍

Section intitulée building-a-symfony-htmx-projectBuilding a Symfony + htmx project

For this introduction to htmx, I am going to create a tiny SPA with Symfony and htmx, with these few features:

Much more can be done with this library, but these basic features will, I think, serve as a great opener to all the possibilities.

If you get lost at any point, you can find the code for this project on GitHub.

Section intitulée setting-up-the-projectSetting up the project

Let’s start with a fresh Symfony project. Once you got your app running, let’s create our main controller, I will call it SpaController.

$ symfony console make:controller SpaController

Let’s now install htmx itself using AssetMapper, and symfonycasts/tailwind-bundle for Tailwind, allowing me to make this demonstration more visually appealing. No Webpack needed.

$ composer require symfonycasts/tailwind-bundle
$ php bin/console tailwind:init
$ php bin/console importmap:require htmx.org
# assets/app.js

import htmx from 'htmx.org';

window.htmx = htmx; // not mandatory, but I use it to access htmx in my pages

My app will contain several pages, so let’s start by creating a navbar.

For now, let’s only include a link to our home page, and one to the first page I am going to create.

# templates/base.html.twig

…
<body>
    <nav hx-boost="true">
        <ul>
            <li>
                <a href="{{ path('app') }}">
                    Home
                </a>
            </li>
            <li>
                <a href="{{ path('app_new_page') }}">
                    New Page
                </a>
            </li>
        </ul>
    </nav>

    {% block body %}{% endblock %}
</body>
…

The template of this new page simply extends the base template, and displays a message:

# templates/spa/index.html.twig

{% extends 'base.html.twig' %}

{% block body %}
    <h1>Welcome to your new page!</h1>
{% endblock %}

By adding the hx-boost attribute to my anchors, or a parent element, I can tell htmx to go fetch a response and replace our current <body> element with the one from the response.

It also allows htmx to create a new location history in the browser despite not reloading the page, and if I were to disable scripts on the page, these anchors would still function like they usually would.

That way, user experience is improved (it’s faster), SEO and bot crawling still works, and we did not have to implement anything: everyone’s happy 😺.

This attribute is very handy for navigation, it’s really easy to use and is in my opinion the cornerstone of navigating with htmx. If you know Symfony UX, this is the equivalent of Turbo Drive.

In can also be used in cases where you do not want to replace the whole body of your page every time a link is clicked, thanks to a few other base attributes of htmx:

  • hx-target lets you decide which element on the current page gets replaced with the freshly fetched content;
  • hx-select lets you decide which part of the response should be used to replace the target element;
  • hx-swap lets you decide what should happen to the element that triggered the request;
  • hx-push-url lets you decide if the URL should be updated or not;
  • hx-post lets you decide which route should be used to fetch the response using POST, the equivalents exist with GET, PATCH, PUT and DELETE.

Some of these attributes will appear again later in this project!

Section intitulée submitting-a-formSubmitting a form

Now that my navigation works, let’s see about adding a form.

Let’s first make a basic entity, I will call it Message, and create the related form.

On the template, the form needs a few htmx attributes:

{{ form_start(form, {'attr': {
    'hx-post': path('app_spa_message'),
    'hx-target': 'body',
}}) }}

Since I want the form to work over AJAX, I gave it the hx-post attribute, which decides where the form will be submitted using POST, and hx-target to decide where the response will be placed.

Without this second attribute, the content of the response would have been placed inside the <form> element, which is not ideal in our use case.

If I go to that new page, my form will show up. So far it might look like no htmx is involved, but if I send a message, no page reload happens!

Though that means that the user cannot be sure that the message was sent either, so let’s add a little alert on the page, to show when something happens.

I will paste this in the base template:

{% if app.request.hasPreviousSession %}
    {% for success in app.flashes('success') %}
        {{ success }}
    {% endfor %}
{% endif %}

And this in the controller, right before the render:

$this->addFlash('success', sprintf('Message "%s" sent!', $message->getContent()));

Now whenever I send a message, a notification should appear, letting me know that it was posted without any issue.

Sending a message should now look like this:

If the backend detects a validation error on the form, it will be displayed as usual, like it was submitted naturally.

Now that I can post messages, I’m going to create a new page where all these messages are displayed.

Section intitulée displaying-dataDisplaying data

Let’s create a new route and template for that. And let’s not forget the link in the nav.

The template looks like this:

# templates/spa/messages/messages.html.twig

{% 'base.html.twig' %}

{% block body %}
    <table>
        <thead>
            <tr>
                <th>Comments</th>
            </tr>
        </thead>
        <tbody
            hx-get="{{ path('app_spa_message_list') }}"
            hx-target="this"
            hx-trigger="load, every 5s">
                <tr>
                    <td>Loading</td>
                </tr>
        </tbody>
    </table>
{% endblock %}

Let’s go over our new attributes:

  • hx-get="{{ path('app_spa_message_list') }}" lets htmx know where to GET the list of messages, I’m making it a reusable route, so that I can use it later on for the search feature;
  • hx-target="this" is an attribute mentioned earlier, I only want to replace the content of the tbody element, so I target it directly using this.
  • hx-trigger="load, every 5s" is new, the hx-trigger attribute lets me define specific events that should trigger my element’s behavior. Here, it tells htmx to refresh the content of the tbody on page load, and then every 5 seconds going forward. This is a great way to keep the data up to date, without having to reload the page.

I have also included a loading message in my <tbody> element, so that the user knows that something is happening while the initial load is taking place.

Now let’s create the route that will handle the data display:

# src/Controller/SpaController.php

…
#[Route('/_/messages/list', name: 'app_spa_message_list')]
public function messageList(MessageRepository $messageRepository): Response
{
    return $this->render('spa/messages/message_list.html.twig', [
        'messages' => $messageRepository->findAll(),
    ]);
}
…

And finally, there is the list:

# templates/spa/messages/message_list.html.twig

{% for message in messages %}
    <tr>
        <td>
            {{ message.content }}
        </td>
    </tr>
{% else %}
    <tr>
        <td>
            No results found
        </td>
    </tr>
{% endfor %}

Now if I open the page, I should see all the messages that have been posted. Thanks to the refresh, I can also see the new messages appear as they are posted.

I really like this hx-trigger attribute, it makes HTML feel more alive, and able to respond to more than basic click events!

I could even think of a refresh based on keyboard input. If I were to add keyup[shiftKey && key == 'L'] from:body to the list of triggers, I could reload the list manually by pressing shift + L on my keyboard.

The filters make it very versatile!

Let’s use this attribute again to very easily create a search bar.

All I really have to do is to add a new input field to my messages.html.twig template, and add the hx-trigger attribute to it:

# templates/spa/messages/messages.html.twig

<input type="search"
       name="search" placeholder="Search for a message"
       hx-get="{{ path('app_spa_search_message') }}"
       hx-trigger="keyup changed delay:100ms, search"
       hx-target="#search-results">
<table>
    <tbody id="search-results">
    </tbody>
</table>

As well as a route to handle the search:

# src/Controller/SpaController.php

#[Route('/search/message', name: 'app_spa_search_message')]
public function searchMessage(Request $request, MessageRepository $messageRepository): Response
{
    $search = $request->get('search');

    return $this->render('spa/messages/message_list.html.twig', [
        'messages' => $messageRepository->getByContentLike($search),
    ]);
}

And that’s it! I can now search for messages, and see the results appear in real time.

And now, to make this app more complete, let’s add some Authentication to it.

Section intitulée authenticationAuthentication

Without going in details, I will use Symfony’s Security component to handle the authentication, Symfony’s Form component to create the login form and the Maker Bundle to create our registration form.

With that out of the way, let’s add a link to the register form to my navbar:

# templates/base.html.twig

<nav hx-boost="true">
...
    <li>
        <a
           href="{{ path('app_register') }}"
           hx-target="#modal-container"
           hx-select="modal"
           hx-push-url="false"
        >
            Register
        </a>
    </li>
...
</nav>

I want to point out two things here:

  • The hx-target attribute is set to #modal-container, this is because I want to display the registration form in a modal, and the hx-boost attribute tells htmx to boost the link, so that it opens in a modal instead of redirecting the user to a new page. The docs do something different using the hx-swap attribute, but I prefer this solution as it makes it easier to handle the modal refresh if there is a form error;
  • I added hx-push-url to the link, and set it to false. This is because I do not want the URL to change when the modal opens, as it is not a new page.

Doing it this way requires adding an element with the id="modal-container" attribute to the base template, this is where the modal will be displayed:

# templates/base.html.twig

...
<div id="modal-container">
    {% block modal %}{% endblock %}
</div>
...

I can now see the registration form appear when I click on the link.

But it will not look like a modal until the proper style is applied, this is something you can cook up yourself or find online. Thankfully, the htmx docs generously give us a modal example, so let’s use that one. It has some bits of Hyperscript in it, but I do not care to include another package in this project, so I will instead use this button I have made to close the modal instead of their Hyperscript one:

<button onclick="htmx.remove(htmx.find('#modal'));">
    Close
</button>

So styling aside, my register template now looks like this:

# templates/registration/register.html.twig

{% extends 'base.html.twig' %}

{% block title %}Register{% endblock %}

{% block modal %}
    <div id="modal">
        <div class="modal-underlay"></div>
        <div class="modal-content">
            <h1>Register</h1>

            {{ form_errors(registrationForm) }}

            {{ form_start(registrationForm) }}
            {{ form_widget(registrationForm._token) }}
            {{ form_row(registrationForm.email) }}
            {{ form_row(registrationForm.plainPassword, {
                label: 'Password'
            }) }}
            <button type="submit" class="btn">Register</button>
            {{ form_end(registrationForm) }}
            <div>
                <button onclick="htmx.remove(htmx.find('#modal'));">
                    Close
                </button>
            </div>
        </div>
    </div>
{% endblock %}

After I’ve added the proper CSS (from the htmx docs), I now have a working modal:

It looks great already, but I need a bit more to make it properly functional.

# src/Controller/RegistrationController.php#register
…
if ($form->isSubmitted() && $form->isValid()) {
    // Save the new User, auto login
    // ...

    $response = new Response();

    $response->headers->set('HX-Location', '/');
    $response->headers->set('HX-Refresh', 'true');

    return $response;
}
…

I have added a custom header to the response, which will tell htmx to refresh the page, and not just the modal. This is necessary to make the rest of the DOM (navbar, debugbar, etc.) update to reflect the fact that I am now logged in. My user state changed on the backend, my frontend has no state, so we refresh everything.

I could still improve this form, and add an event similar to the search, that tells the user dynamically if the email they have entered is available or not. But that’s just an idea, I won’t implement it here.

For the login form, I will need to add the same logic, and this is done by customizing our success handler:

# src/Security/Authentication/AuthenticationSuccessHandler.php

class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        $response = new Response();

        $response->headers->set('HX-Location', '/');
        $response->headers->set('HX-Refresh', 'true');

        return $response;
    }
}

# config/packages/security.yaml

security:
  firewalls:
    main:
      form_login:
        success_handler: App\Security\Authentication\AuthenticationSuccessHandler

I am almost done!

When working with modals, it can be tricky to handle errors. I want my modals to be the only content reloaded on the page when that happens (and not trigger a full reload which would also relocate me to /register or /login).

That’s doable by adding this attributes to the forms:

# templates/registration/register.html.twig

...
{{ form_start(registrationForm, {
    'attr': {
        'hx-post': path('app_register'),
        'hx-target': '#modal-container',
        'hx-select': '#modal',
    }
}) }}
# templates/login/index.html.twig

…
<form action="{{ path('app_login') }}"
      method="post"
      hx-post="{{ path('app_login') }}"
      hx-target="#modal-container"
      hx-select="#modal">
…

And that’s it! I can now register and log in to my app.

Section intitulée conclusionConclusion

With these few tricks, I now have a fairly (small but) functioning app! It isn’t complete by any means, for instance the navbar should show a logout link when I am connected, but with everything I have covered, it should be easy to implement.

Regarding the htmx code, there is a lot of things I could do to improve it, off the top of my head I’d say:

  • Implementing the dynamic validation mentioned on the register form;
  • Making use of the View Transition API;
  • Handling backend failure or downtime with response-targets;
  • Making it all run when Javascript is disabled.

And there are a ton of attributes this article does not cover, but this could all be content for a possible second article.

I was inspired by this great book written by the creators of htmx. It does an excellent job of explaining where the idea behind htmx comes from, and why you might (or might not) want to favor a hypermedia-friendly approach in comparison with a Javascript one.

htmx feels like writing backend-only applications does not mean bad user experience anymore and we love it!

Commentaires et discussions

Ces clients ont profité de notre expertise