Give a SPA Feel to Your Static Website with Hotwire's Turbo

Jean-Baptiste Kaiser
Jean-Baptiste KaiserApril 18, 2025
#js#performance

Turbo is a JavaScript library promising to give a Single Page App feel to a static website without requiring to write JS code. We tested it on the React Admin documentation pages, but faced several issues that made this solution impractical to us.

Building Statically Generated Documentation With Dynamic Features

Like most of Marmelab's projects, React Admin uses GitHub to host its source code. But did you know it also uses GitHub to host its documentation? That's right. The React Admin documentation website is served using GitHub Pages.

React Admin documentation website

Under the hood, GitHub Pages use Jekyll, a Ruby framework, to generate HTML pages from Markdown files, and serve them with a very simple web server.

Jekyll logo

This works well for sites with static content, like documentation pages. But most documentation websites soon find themselves requiring more dynamic features. Like adding a dynamic table of content, showing code examples in more than one language (TypeScript / JavaScript, or JSON / YAML configuration file), or simply adding a search input.

But there are also features requiring some dynamism that are less obvious. For instance, did you notice that navigating to a React Admin documentation page from the left menu will actually preserve the scroll position in the menu?

This helps the user keep track of where they are in the documentation tree, and is a must-have to avoid the feeling of getting lost in a dense website (React Admin having almost 270 doc pages). Once you notice that, you will also immediately notice the frustration generated by websites that don't offer this feature.

React Admin uses custom client-side JavaScript code to accomplish this, intercepting click events and turning them into AJAX calls by requesting the next page content using fetch and replacing the content directly in the DOM.

This custom code works decently well, but it has some flaws. For instance, with our current implementation bugs can sometimes occur when hitting the browser's back button. Couldn't we use a more robust and maintained library to do that for us?

Well, there's this library called Turbo, which could happen to be exactly what we are after.

Introducing Turbo

Turbo logo

Turbo is a JavaScript framework, part of the Hotwire solution.

To say a few words about Hotwire, it's presented as an alternative approach to building modern web applications without using much JavaScript, by sending HTML instead of JSON over the wire. The name comes from this goal: sending HTML Over The Wire.

In this solution, Turbo is the library responsible for doing the client-side work (we'll dive deeper into the details just below).

There are two other parts constituting Turbo: Stimulus, another JS framework making it easier to add interactivity to HTML components, and Native, a web-first framework for building native mobile apps.

For our needs, we only require using Turbo.

Turbo's main goal is to provide a SPA feel without requiring to write JavaScript.

The idea is to have the browser deal with just the final HTML, as opposed to other stacks relying on JSON sent by the server and client-side code to process it and update the DOM accordingly.

This is what makes it interesting in our case: it can deal with the raw HTML content generated by Jekyll, and doesn't require a backend layer to serve content in another form such as JSON.

Turbo uses a set of complementary techniques to achieve that goal, which we'll go over one by one.

Turbo Drive

Turbo Drive is the piece of the framework responsible for handling navigation. It does a very similar job to our custom script (i.e. intercept navigation attempts and replace them with AJAX calls), but it offers more features.

First, it manages the browser URL and history stack, and keeps all visited pages in a local cache (to speed up subsequent navigations).

It is also capable of prefetching links on hover, and preloading links into the cache.

Turbo Drive will intercept navigation triggered by navigation links or form submissions. It is enabled by default on all links as soon as the library is included in the web page.

By default, Turbo Drive will replace the content of the document's <body>, and the content of their <head> will be merged. The point of merging instead of replacing the <head> elements is that if links to assets (JS, CSS, ...) are the same, they won’t be touched and therefore the browser won’t process them again.

Turbo Morphing

Turbo offers an opt-in feature allowing to morph the content into the DOM, instead of replacing it.

This allows for features like scroll preservation, event listener preservation, and avoids having to parse and execute scripts again. It's an alternative to other techniques found in Single Page App frameworks like the Virtual DOM, used by React and Vue.

You can configure how Turbo handles page refresh with a <meta> tag in the page’s head.

<head>
  <meta name="turbo-refresh-method" content="morph">
</head>

Scroll preservation is configured in a similar way.

<head>
  <meta name="turbo-refresh-scroll" content="preserve">
</head>

Morphing can also be enabled on specific Turbo Frames.

Turbo Frames

Turbo allows to decompose pages into Frames. This allows to limit the scope of the content that will be updated, and can also be used for an action to target another frame.

Frames are created by wrapping a segment of the page in a <turbo-frame> element.

<body>
  <div id="navigation">
    <!-- Link targeting the entire page -->
    <a href="/profile">Profile</a>
  </div>

  <turbo-frame id="message">
    <h1>My message title</h1>
    <p>My message content</p>
    <!-- Link targeting the "message" frame -->
    <a href="/messages/1/edit">Edit this message</a>
  </turbo-frame>
</body>

Any links and forms inside a frame are captured, and the frame contents automatically update after receiving a response. Regardless of whether the server provides a full document, or just a fragment containing an updated version of the requested frame, only that particular frame will be extracted from the response to replace the existing content.

Frames can also be used to have Turbo load content eagerly or lazily.

By default, navigations happening within a Frame are not considered page visits (i.e. won't advance the browser history). We can promote navigations to visits with the [data-turbo-action] attribute.

<turbo-frame id="articles" data-turbo-action="advance">
  <a href="/articles?page=2" rel="next">Next page</a>
</turbo-frame>

Turbo Streams

We didn't use Turbo Streams for our experiment, but think it is still worth mentioning for completeness.

In a nutshell, Turbo Streams allow to deliver page changes as fragments of HTML wrapped in <turbo-stream> elements, which can be delivered to the browser synchronously as a classic HTTP response, or asynchronously over transports such as WebSockets or Server Sent Events (SSE).

The <turbo-stream> elements include target and action attributes, which tell Turbo how it should use the received content to update the page.

Here are a few examples:

<turbo-stream action="append" target="messages">
  <template>
    <div id="message_1">
      This div will be appended to the element with the DOM ID "messages".
    </div>
  </template>
</turbo-stream>

<turbo-stream action="prepend" target="messages">
  <template>
    <div id="message_1">
      This div will be prepended to the element with the DOM ID "messages".
    </div>
  </template>
</turbo-stream>

<turbo-stream action="replace" target="message_1">
  <template>
    <div id="message_1">
      This div will replace the existing element with the DOM ID "message_1".
    </div>
  </template>
</turbo-stream>

Using Turbo On Our Website

To start using Turbo, we can get the latest version from a CDN, and include a <script> tag in the <head> of your application:

<head>
  <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@latest/dist/turbo.es2017-esm.min.js"></script>
</head>

That's enough for Turbo Drive to take over our navigation links.

We can also customize Turbo a bit. For instance, we can add the following <meta> tag to enable the View Transition API:

<head>
  <meta name="view-transition" content="same-origin" />
</head>

Now, if we open the browser developer tools, we'll see that page navigations now trigger fetch calls, and leverage the View Transition API (especially visible when navigating backwards).

Decomposing Into Frames

Our first approach was based on the following assessment: in React Admin documentation when navigating to another page, the only parts that need updating are:

  • The main documentation container <div>, i.e. the <div> containing the HTML generated from the corresponding Markdown file
  • The table of contents (which is generated with Tocbot, and is a child of the main container <div>)
  • The left menu, where the only thing that really changes is the active status of the list item

With that analysis in mind, it made sense to:

  • Wrap the main container <div> in a <turbo-frame>
  • Update the TOC after every Turbo visit
  • Update the selected item in the left menu after every Turbo Visit

Here is how we defined the Turbo Frame:

+<turbo-frame
+    id="content-container"
+    data-turbo-action="advance"
+    autoscroll
+    data-autoscroll-block="start"
+>
    <div class="markdown-section DocSearch-content toc-content">
        {{ content }}
    </div>
+</turbo-frame>
  • data-turbo-action allows to specify that the default Turbo Drive action should be "advance", to promote the frame navigation to a page visit
  • autoscroll allows to scroll the frame into view when it is rendered
  • data-autoscroll-block allows to control the vertical alignment, in this case scrolling to the "start" of the block

documentation split into Turbo Frames

To update the TOC and the selected item in the menu, we need to subscribe to the "turbo:load" event:

document.addEventListener('turbo:load', function (event) {
    buildPageToC();
    changeSelectedMenu();
});

The "turbo:load" event is fired at initial page load, and again after every Turbo visit.

While this solution seemed sensible at first, it turned out to oppose 3 difficulties.

Difficulty 1: When clicking a link from within the main container, the Turbo Frame reloaded as expected. But when clicking a link from the left menu, the full <body> was reloaded. That made sense because the navigation originates from a link outside the Turbo Frame. Hence, we had to add data-turbo-frame="content-container" to each link in the left menu to target that Turbo Frame. This solved the issue.

Difficulty 2: While Turbo offers two modes regarding scroll, reset and preserve, it offers no solution to choose the mode at the frame level (e.g. reset the content container but preserve the left menu scroll position). This forced us to keep using custom JS code to scroll the active menu item into view.

Difficulty 3: The most critical difficulty was this one: anchors are lost when clicking on a link targeting another page from within a Turbo Frame! This means if I clicked a link targeting https://marmelab.com/react-admin/Admin.html#theme, it would redirect me to the top of the Admin.html page. This is a deal-breaker for a documentation website. The sad part is this issue was reported to Turbo in 2022, but as of today, there is still no fix available.

GitHub issue #598 on Turbo's repo

Thus, we had to come up with another approach.

Leveraging Morphing

Our new rationale was the following: we can't use Turbo Frame because of the major issue mentioned earlier. But after all, shouldn't morphing allow us to preserve most of the DOM content along with the scroll position?

Following this idea, we went ahead and removed the Turbo frame introduced earlier, and configured morphing along with scroll restoration:

<head>
  <meta name="turbo-refresh-method" content="morph" />
  <meta name="turbo-refresh-scroll" content="preserve" />
</head>

We also moved the scripts we had in the <body> to the <head>, because otherwise they would be re-executed each time a Turbo visit happens.

Attentive readers may have noticed that this last sentence goes against what we said earlier about morphing being able to not re-execute scripts if their source hasn't changed. That's a valid point! Actually morphing should have avoided that issue, but it turns out this was caused by another issue I'll introduce just below.

Unfortunately, this new approach turned out to be unsuccessful, too.

While it mostly worked (navigating from either the main container or the left menu worked well, including anchors), the scroll position was still not preserved.

What's worse, a flicker would occur when following a link with an anchor targeting a page that's already in the cache. This would lead to the scroll position being offset, showing the wrong doc section.

Despite my efforts to identify the root cause of this issue, investigating leads like a race condition between Turbo Drive's and Tocbot's anchor management, I just couldn't find what was causing it.

But after a while I realized that all these issues may actually come down to this single one: it turned out morphing was actually not enabled, despite the <meta name="turbo-refresh-method" content="morph" /> element set in the <head> section!

Here too, even after investigating for more than one hour, I couldn't find out why morphing refused to enable.

What We Ended Up With

As you saw, the end result of our experiment was a bit of a disappointment. We were left with issues that are simply not acceptable for a documentation website: either have some anchor links discard the anchor or have flickering and offset in the scroll position for pages that are already in the cache.

Nonetheless, our experiment showed that Turbo could indeed fix some issues we have with the existing code:

  • The browser's back button worked properly
  • The browser's page title was correctly updated on page change

Besides it brought some nice features:

  • Preloading links into the cache worked well, and could probably help improve the website's performance a bit
  • The View Transition API was enabled on browsers supporting it, which offered a nice transition effect (customizable with CSS) when loading a new page

However, our tests also showed that:

  • The amount of custom code we could replace with Turbo was not that significant (approx. 40 lines of JS)
  • Preloading put aside, there was no noticeable difference in performance (i.e. navigation and rendering speed) compared to our current solution

Conclusion

Despite the somewhat underwhelming results exposed above, we still consider Turbo to be an interesting library. Apart from the issues we had with anchors and morphing (the latter possibly coming from a miscomprehension on my end), the library does its job pretty well, and the developer experience is decently good.

To their credit, Turbo was still able to solve two existing bugs we had on the website, notably the back button support.

Unfortunately, the issues brought in the process were simply too big to be ignored in the context of a documentation website.

In the end, it did not reduce the JS code by that much, nor did it noticeably improve the performance.

My current opinion regarding Turbo is that, for an experienced JavaScript developer, it does not bring that much to the table. Nevertheless, it is still a framework I could consider recommending to developers who are not at ease with vanilla frontend JS, namely backend devs who are used to serving fully rendered HTML content.

All in all, this experiment made me realize that a documentation website has to be a hybrid app to offer good UX. Static pages are just not enough. As it turns out, most documentation websites I visit on a daily basis already are! I just never realized it until now somehow.

Did you like this article? Share it!