You don't need Rails to start using Hotwire

You Don't Need Rails to Start Using Hotwire

Although Hotwire is closely tied to Ruby on Rails, you might be surprised to know that you don't really need Rails to learn, play, and experiment with Hotwire. In this article, we'll build a simple static site that uses Turbo Drive + Frames to make it responsive and use Streams to make it dynamic.

21 min read
💡
If you are interested in learning about Hotwire, check out my crash-course on Hotwire's Turbo framework.
Update: I originally published this post few months ago, and it only covered Turbo Drive and Turbo Frames then, with a static site.

I've since had a bunch of conversations with people working with other tech stacks (Rust, PHP, and Go) wanting to integrate Hotwire into their front-ends, and everyone kept asking about Turbo Streams, since it needs a back-end server. So I've updated the post to build a simple Sinatra app that uses Turbo Streams.

So far on this blog, we've learned about Hotwire and built a hotwired to-do list powered by Rails. We've also seen how you can iteratively build and progressively enhance an application using Hotwire. All these projects used Hotwire with Ruby on Rails.

However, you don't really have to use Rails (or Ruby) to get most of the benefits of Hotwire. Most static websites can just drop-in the Turbo library to behave like responsive single-page applications, without incurring any of the costs and complexities associated with the SPA frameworks. And if you have an existing app written in PHP, Go, Rust, or even Java, you can start using Hotwire, right now.

All you have to do is follow certain conventions.

This article is divided into two parts:

  1. First one shows how you can use the first two Hotwire techniques (Turbo Drive and Turbo Frames) in a simple static website, to fetch and update entire web pages or parts of the page, without fully-reloading the browser. We'll use a simple static site to demo this.
  2. Second part shows how to tweak your existing back-end code to send Turbo Streams to update multiple parts on your website dynamically, in response to form submissions. Since Turbo Streams work with form submissions, I'll use Sinatra to demo this.

Here're the topics we'll cover in this article.

💡
For those of you who don't enjoy reading long, rambling posts that try to explain every nook and cranny of the topic at hand, I've uploaded the finished project on my GitHub account, which also contains the instructions to get started. Just clone the repository, run npm run launch, and you're good to go.

Prerequisites: If you don't know what Hotwire is, I suggest you check out the following article, written by yours truly. It briefly introduces Hotwire and explains the problems it solves.

A Brief Introduction to Hotwire
Hotwire, which stands for HTML Over the Wire, provides a different way to build modern web applications without using too much JavaScript. This article provides a quick introduction to Hotwire and it’s component frameworks, such as Turbo Drive, Frames, and Streams.

Set up a Simple Website

In this section, we'll set up a simple website that serves static files. Both Turbo Frames and Turbo Drive don't need a backend server, so a simple static website should be simple and barebones enough to explain the basic concepts.

Create a brand new directory for your website and cd into it. I'll call mine wireframe.

➜ mkdir wireframe
➜ cd wireframe

Run the npm init command to set up a new project. It will ask you a bunch of questions and then create a package.json in the current directory.

➜ wireframe npm init

package name: (wireframe)
version: (1.0.0)
description: Using Hotwire without Ruby on Rails
entry point: (index.js)
test command:
git repository:
keywords: hotwire, turbo
author: AK
license: (ISC) MIT

Here's the resulting package.json file

{
  "name": "wireframe",
  "version": "1.0.0",
  "description": "Using Hotwire without Ruby on Rails",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "hotwire",
    "turbo"
  ],
  "author": "AK",
  "license": "MIT"
}

Now open the directory in your favorite editor. We're ready to start coding.

➜ code .

Add a simple HTML file

Let's create a new folder named public with an index.html HTML file in it. The HTML file will have the following content. Feel free to copy and paste.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
  <title>Wireframe</title>
</head>
<body>
  <header>
    <nav>
      <a href="/">Home</a>
      <a href="/contact">Contact</a>
      <a href="/about">About</a>
    </nav>

    <h1>Learn Hotwire</h1>

    <p>Yes, you can really use Hotwire without Rails. Give it a try!</p>
  </header>
  <main>
  </main>
</body>
</html>

To make our website look a bit nice, I am using SimpleCSS, a simple, classless CSS framework. It's so simple that I don't even have to explain it. Basically, it makes semantic HTML look good, that's it. No classes required.

If you open the index.html file directly in the browser, it should look like this. Pretty neat, right?

index.html in browser
index.html in browser

Notice that the nav bar says File , instead of HTTP, because chrome is directly serving the file. To make it look like a real website served with the HTTP protocol, we'll need an HTTP server that will serve the HTML.

But why? If you're curious to learn more about the difference between opening a HTML file directly in browser vs. serving it with an HTTP server, check out this StackOverflow question.

Using an HTTP Server to Serve Static Pages

I am going to use a simple static HTTP server called http-server which is more than sufficient for our needs.

http-server is a simple, zero-configuration command-line static HTTP server. It is powerful enough for production usage, but it's simple and hackable enough to be used for testing, local development and learning.

Did you know that you can run the package without installing it first, using the npx command?

Run the following command from the wireframe directory.

➜ npx http-server

Starting up http-server, serving ./public

http-server version: 14.1.0

Available on:
  http://127.0.0.1:8080
  http://10.0.0.182:8080
Hit CTRL-C to stop the server

Without any arguments, the above command serves the index.html file in the public directory when you visit http://127.0.0.1:8080 or localhost:8080. This is why I'd asked you to create a public/index.html file for your project.

You can also add the above command as a script in the package.json file. This will allow you to launch the website using the npm run launch command.

// package.json

"scripts": {
  "launch": "npx http-server"
},

Now that your server is up and running, visit the http://127.0.0.1:8080 or http://localhost:8080 URL in the browser.

HTTP Browser
HTTP Browser

Now that our website is up and running, we're ready to install Turbo.

How to Install Turbo

We are going to use the pre-compiled, optimized NPM package from skypack.dev using the <script> tag, just like it's 2007. For other installation methods, check out the Installing Turbo documentation.

Step 1: Add the following script tag just above the <title> tag in your HTML.

<script type="module">
  import * as Turbo from 'https://cdn.skypack.dev/@hotwired/turbo@7.1.0';
</script>

<title>Wireframe</title>

Step 2: There's no step 2. ;)

If you're curious about how the above snippet works, I highly recommend you read the MDN documentation on JavaScript Modules.

That's it. Our little website is using Turbo.

To verify, reload the browser, open the DevTools window, go to the Console tab, and type Turbo in it. If it doesn't throw an error, you're good to go.

Installing Turbo
Installing Turbo

Now that we've successfully installed Turbo, we're ready to use it. We'll start with the first big component in Turbo, called Turbo Drive.

Faster Navigation with Turbo Drive

It just works out-of-box.

The best thing about Turbo Drive is that you get it for free. Yes, you heard that right. You don't have to do anything to get the benefits of Turbo Drive.

But how does it work?

When you click a link or submit a form (to the same domain), Turbo Drive does the following:

  1. Prevent the browser from following the link,
  2. Change the browser URL using the History API,
  3. Request the new page using a fetch request
  4. Render the response HTML by replacing the current <body> element with the response and merging the <head>.

The JavaScript window and document objects as well as the <html> element persist from one rendering to the next.

The same goes for an HTML form. Turbo Drive intercepts and converts Form submissions into fetch requests. Then it follows the redirect and renders the HTML response.

As a result, your browser doesn’t have to reload, and the website feels much faster and more responsive, just like a single-page application.

Let's Add a Contact Page

To see how Turbo Drive works, we need to set up another page on our website that we'll add a link to.

We've already added the links to the Contact and About pages when we wrote the initial HTML, so let's go ahead and add a Contact page. To keep it really simple, I'll just copy and paste the index.html page, changing the filename and a little content to make it unique.

This is only for demo. Your back-end framework or static-site generator uses a templating system to extract all the duplicate HTML.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
  <script type="module">
    import * as Turbo from 'https://cdn.skypack.dev/@hotwired/turbo@7.1.0';
  </script>
  <title>Wireframe - Contact</title>
</head>
<body>
  <header>
    <nav>
      <a href="/">Home</a>
      <a href="/contact">Contact</a>
      <a href="/about">About</a>
    </nav>

    <h1>Contact</h1>
  </header>
  <main>
    <p>You can reach me at akshay.khot@hey.com</p>
  </main>
</body>
</html>

You should see the following page when you go to /contact page.

contact page
contact page

Now go ahead and click back and forth between the Home and Contact links.

💡
Can you spot something different that you won't see on a traditional website with a bunch of HTML pages linking to each other?

No? let me give you a hint. Comment out the <script> tags that load the Turbo library on both pages.

<!-- <script type="module">
  import * as Turbo from 'https://cdn.skypack.dev/@hotwired/turbo@7.1.0';
</script> -->

Don't forget to comment it on both pages, okay?

Now clear the cache and hard reload the browser by pressing and holding the reload button while the DevTools window is open. This removes the Turbo library you loaded earlier from the website.

clear cache and hard reload
clear cache and hard reload

Now click between Home and Contact links.

See something different?

I'm sure you must have figured it out by now. When you navigate between multiple pages, the browser is doing a full reload. You can see this by noticing the earth icon in the tab, which spins for a quick second when you go to a different page.

full reload
full reload

Now uncomment the script from both pages and reload the browser again. No need to clear the cache this time. The website should fetch the Turbo library without any issues.

Go ahead, and click between the pages.

Get it?

The earth is not spinning anymore! The pages are updating without a full browser reload. How cool is that?

This is the power of Turbo Drive. Without any extra effort on your part, your website has instantly become more responsive and dynamic.

To recap, here's what happened when you clicked on the Contact link.

  1. Turbo intercepted that click, prevented the browser from following it, made a fetch request to get the content of the Contact page.
  2. Upon receiving the HTTP response, Turbo then replaced the current body of the web page with the body of the result.
  3. Additionally, it merged the contents of the <head> tag if there's new stuff here, like new <meta> tags or new JavaScript. In our case, there wasn't any new stuff, so it left the head tag as it is.
💡
Instead of clicking a link, you can also visit a link programmatically by calling Turbo.visit(location).

Displaying a Progress Bar

You can improve the perceived responsiveness of your website by displaying a progress bar while Turbo fetches the new page. Simply add the following CSS that targets the .turbo-progress-bar element.

<style>
  .turbo-progress-bar {
    height: 10px;
    background-color: green;
  }
</style>

It might be hard to see it, as the navigation is so fast. You can throttle the network to Slow 3G to see the progress bar.

That's the essence of how Turbo Drive works. You get a bulk of the benefits of modern single-page applications, with a fraction of the complexity associated with the complicated SPA frameworks.

You can check out the documentation to learn more about the advanced features of Turbo Drive. But for now, let's move on to Turbo Frames.

Dynamic Page Updates with Turbo Frames

We've seen how Turbo Drive can make your website responsive by replacing the current body element with the response body.

For most websites (that are not web applications), this is absolutely enough to get the majority of performance boost and to give that SPA-like feel without any added complexity.

However, sometimes you have a website that only needs to update a small section on the page while leaving the whole page intact.

Imagine a blog with comments enabled (just like this blog you're reading this post on). When someone adds a comment to my post, I only want to update the comments section, without updating the whole blog post. Replacing the whole body doesn't make sense here.

Adding comments to a blog post

This is the appeal behind single-page applications, where most of the page remains as it is, and only sections on the page are updated independently.

What if you could just send the specific HTML that changed, i.e. the comments section, without touching the rest of the page, i.e. the blog post? The response would be much smaller, and the rest of the HTML could be easily cached, making the application even more responsive.

For this, we need to bring out the next weapon in our arsenal: Turbo Frames. Turbo Frames allows us to do the exactly same thing.

The only way Turbo Frames differ from SPA JavaScript frameworks is this: the part of the page that's updated is retrieved from the response HTML, instead of making an API call to retrieve the JSON response.

What are Turbo Frames?

Turbo Frames allow you to dynamically update sections on the page in response to some action, such as clicking a link or submitting a form.

💡
In contrast to Turbo Drive, Turbo Frames let you contain the scope of the change, reducing the size of the HTML your server has to send.

I will demonstrate Turbo Frames by building a simple gallery on our home page. Here's how the resulting page will look.

Gallery Demo
Clicking on the Next link dynamically updates the image without reloading the browser.

Let's get started. First, update the index.html page's <main> tag with the following HTML. Just copy and paste it, I'll explain what's going on soon. All I did is added an image and the link, and wrapped them in a <turbo-frame> tag.

<main style="text-align: center;">
  <h1>Gallery</h1>
  <turbo-frame id="gallery">
    <img src="images/ocean.jpeg" alt="Ocean" width="500" height="400">
    
    <div>
      <a href="/gallery/forest">Next (forest)</a>
    </div>
  </turbo-frame>
</main>

Then create a new folder named gallery in the public directory. It contains three HTML files named forest.html, mountains.html, and ocean.html. Here's their content.

<!-- forest.html -->

<turbo-frame id="gallery">
  <img src="images/forest.jpeg" alt="forest" width="500" height="400">
  
  <div>
    <a href="/gallery/mountains">Next (mountains)</a>
  </div>
</turbo-frame>

<!-- mountains.html -->

<turbo-frame id="gallery">
  <img src="images/mountains.jpeg" alt="Mountains" width="500" height="400">
  
  <div>
    <a href="/gallery/ocean">Next (ocean)</a>
  </div>
</turbo-frame>

<!-- ocean.html -->

<turbo-frame id="gallery">
  <img src="images/ocean.jpeg" alt="Ocean" width="500" height="400">
  
  <div>
    <a href="/gallery/forest">Next (forest)</a>
  </div>
</turbo-frame>

I've also added three new images in the public/images directory that I grabbed from the w3schools website (or you can also find them in this project's GitHub repository).

That's all needed for our image gallery. Clicking on the Next link updates the picture without reloading the page. Rest of the page, like the <header> content doesn't change.

Wait, what just happened?

How Turbo Frames Work?

You may have noticed the new HTML element named <turbo-frame>. It's a custom HTML element provided by Turbo. This element allows you to divide your website into independent components that need to be changed independently. Let's see how they work, in three simple steps:

Step one: Wrap the component in a turbo frame.

You wrap the section on the page that you want to update in response to link clicks or form submissions inside a <turbo-frame> element and give it a sensible id.

In our example, I only want to update the image and the link below it, so I wrapped it inside a turbo frame with the ID gallery.

<header>
   <!-- header content remains unchanged -->
</header>

<main style="text-align: center;">
  <h1>Gallery</h1>
  <turbo-frame id="gallery">
    <img src="images/ocean.jpeg" alt="Ocean" width="500" height="400">
    
    <div>
      <a href="/gallery/forest">Next (forest)</a>
    </div>
  </turbo-frame>
</main>

Step two: Wrap the response in a turbo frame with same ID.

Any HTML that you want to send from the server, you wrap it in a turbo frame and give it the same ID as the original component. This is how Turbo figures out which frame to update on the page.

For example, here is the forest page that contains the new image and the new link, both wrapped in a turbo frame with the ID gallery.

<!-- forest.html -->

<turbo-frame id="gallery">
  <img src="images/forest.jpeg" alt="forest" width="500" height="400">
  
  <div>
    <a href="/gallery/mountains">Next (mountains)</a>
  </div>
</turbo-frame>

Step three: There's no step three ;)

When you click the link and the response from the server arrives, Turbo finds the <turbo-frame> element with the matching ID, and replaces the current <turbo-frame> element with the one from the response.

In the above example, when you click the Next button, the <turbo-frame id="gallery"> element on the index.html page is replaced with the matching turbo frame element that arrives from the response. That's how the image + link is replaced with a new image + link.

💡
It doesn't matter if the server provides a full HTML document, or just a fragment containing an updated version of the requested frame (like we do above), only that particular frame will be extracted from the response to replace the existing content.

You can have multiple <turbo-frame> elements on the page. Each one should have its own, unique ID. That's how Turbo knows which frame to replace when the response arrives from the server.

However, keep in mind that at any given request-response cycle, only one Turbo Frame will be swapped. If you need to update multiple components on the site, you'll have to use Turbo Streams, which we'll explore next.

Working with Turbo Streams

Turbo Streams deliver page changes as fragments of HTML wrapped in <turbo-stream> tags. Each stream element specifies an action together with a target ID to declare what should happen to the HTML inside it.

I'll demo the Turbo Streams with a different example, since you need to handle form submissions for Turbo Streams, and our static site can't do that. So I'll use Sinatra, an elegant Ruby web framework.

Create a Sinatra Project

First, let's install the Sinatra framework and Puma web server using the gem install command.

gem install sinatra
gem install puma

Create a new directory for the project. I'll call mine wirestream. Navigate into it and open it in your favorite browser.

mkdir wirestream
cd wirestream
code .

Create a new Ruby script called app.rb that adds a route for the home page.

require 'sinatra'

get '/' do
  'Sinatra says hello!'
end

Now run that script just like any other Ruby script:

ruby app.rb

Sinatra is up and running and serving your web application at localhost:8000.

Sinatra App
💡
That's it. This is a complete web application. Didn't I tell you how simple Sinatra is? How cool is that?

Render a Template

Before we use Turbo Streams, let's set up a proper HTML template, just like Rails.

require 'sinatra'

get '/' do
  erb :index
end

Passing the :index symbol tells Sinatra to look for a index.erb template in the views directory. Just copy + paste the following HTML.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
  <title>Wireframe</title>
</head>
<body>
  <header>
    <nav>
      <a href="/">Home</a>
      <a href="/contact">Contact</a>
      <a href="/about">About</a>
    </nav>

    <h1>Learn Hotwire</h1>

    <p>Yes, you can really use Hotwire without Rails. Give it a try!</p>
  </header>
  <main>
  
    <div style="color: green; text-align: center;">
      <div id="subscriber-notification"></div>
    </div>
    
    <div style="width: 50%; margin: 2em auto; float: left;">
      <div id="newsletter">
        <form action="/subscribe" method="post">
          <div>
            <input type="text" name="name" id="name" placeholder="Enter your name" />
          </div>

          <br />

          <div>
            <input type="email" name="email" id="email" placeholder="Enter your email" />
          </div>

          <br />
          <button type="submit">Subscribe</button>
        </form>
      </div>
    </div>

    <div style="width: 50%; margin: 0 auto; float: right;">
      <h4>Subscriber List</h4>
      <ul id="subscriber-list">
        <li>Yukihiro 'Matz' Matsumoto</li>
        <li>Jason Fried</li>
      </ul>
    </div>

  </main>
</body>
</html>

Let's restart Sinatra and reload the page. As you can see, we've added a simple newsletter form and a subscriber list.

Sinatra Newsletter

Whenever someone enters their name + email and hits "Subscribe", we want to add their name to the "Subscriber List", and also show a notification-like header at the top. Like this:

Stream Update
Stream Update

Let's see how you'd accomplish this using Turbo Streams.

Target Multiple Elements with Turbo Streams

The very first thing we'll need is a new route to handle form submissions. Let's add a new /subscribe route that handles a form POST submission.

post '/subscribe' do
  @name = params['name']
  erb :subscribe
end

All it's doing is accepting a POST request to /subscribe, getting the entered name and saving it to an instance variable (just like Rails) and return a views/subscribe.erb template. Since our form already submits to /subscribe, this route will handle the form submission.

Next, let's add a simple subscribe.erb template under the views directory. For now, it says that new user has subscribed.

<h1> 
  <%= @name %> has subscribed!
</h1>

Restart the app, and submit the form after entering the name and email. You should see the following page.

Subscriber Feedback

Now, instead of rendering a separate /subscribe page, we want to send a Turbo Stream that update multiple elements at the same time. For this, let's first add the Turbo library to our app, just like we did earlier. Under the <head> tag, add the following code.

<script type="module">
  import * as Turbo from 'https://cdn.skypack.dev/@hotwired/turbo@7.1.0';
</script>

Now that we've Turbo, we'll accomplish the multiple dynamic Turbo Stream updates in two simple steps:

Step One: Use Turbo HTTP Header

For the Turbo JavaScript library to identify the Turbo HTTP response from the server, the response needs to have a special HTTP header that indicates the Content Type, as follows:

Content-Type: 'text/vnd.turbo-stream.html'

As long as the response has this header, the Turbo library will treat it as a Turbo Stream response and treat it accordingly.

Adding a new header in Sinatra is very simple.

post '/subscribe' do
  @name = params['name']

  response.headers['Content-Type'] = 'text/vnd.turbo-stream.html'

  erb :subscribe
end

Step Two: Send Turbo Streams in Response

Replace the existing content of the subscribe.erb template with the following code, which contains two separate Turbo Stream responses containing following action attributes:

  1. replace: to swap the existing content of an element with the ID subscriber-notification with the content inside the <template> tag.
  2. append: to append the contents inside the <template> tag to the element with the ID subscriber-list.
<turbo-stream action="replace" target="subscriber-notification">
  <template>
    <div id="subscriber-notification">
      <%= @name %> has subscribed!
    </div>
  </template>
</turbo-stream>

<turbo-stream action="append" target="subscriber-list">
  <template>
    <li><%= params[:name] %></li>
  </template>
</turbo-stream>

If you notice the HTML that you copy+pasted earlier in the index.erb template, it contains these two elements:

<div style="color: green; text-align: center;">
  <div id="subscriber-notification"></div>
</div>

<ul id="subscriber-list">
  <li>Yukihiro 'Matz' Matsumoto</li>
  <li>Jason Fried</li>
</ul>

So, in its essence, we are instructing Turbo to replace the empty notification div element with another div containing the subscriber name, and to append the name of the new subscriber to the existing list.

To learn more about various Turbo Stream actions, check out its documentation.

That's it. You're all set. Restart the app, fill out the form, and hit submit. You should see the green notification as well as the name of the new subscriber.

Success!
Success!

Where to go from here

This wraps up our exploration into using Hotwire (without Rails) on a static website as well as a web app not using Rails.

Over the last few months, I've written multiple articles on Hotwire, as I've found it an excellent way to build single-page web apps without incurring the complexities associated with so-called modern SPA frameworks like React or Vue.

Check them out:

A Brief Introduction to Hotwire
Hotwire, which stands for HTML Over the Wire, provides a different way to build modern web applications without using too much JavaScript. This article provides a quick introduction to Hotwire and its component frameworks, such as Turbo Drive, Frames, and Streams.
Progressive Application Development with Hotwire
This is the day when you fall in love with Hotwire. We’re going to build a simple counter. But we’re not going to build it once and be done with it. Instead, we’ll build and progressively enhance it with all three frameworks in Hotwire: Turbo Drive, Turbo Frames, and Turbo Streams.
Let’s Learn Ruby on Rails + Hotwire by Building a To-Do List
In this article, we’ll learn Ruby on Rails and Hotwire by building a to-do list from scratch. It shows how to build single-page web applications using traditional web architecture (server-rendered HTML), without the complexity of modern JavaScript frameworks like React.

If you enjoy writing Ruby, PHP, Python, Go, Rust, or any other back-end language (including JavaScript via node), and keep your front-end as simple as possible while still retaining the interactivity, I highly recommend you check it out.


That's a wrap. I hope you liked this article and you learned something new. If you're new to the blog, check out the full archive to see all the posts I've written so far or the favorites page for the most popular articles on this blog.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.