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:
- 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.
- 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.
- How to set up a simple website
- Using an HTTP server to serve static pages
- How to install Turbo
- Faster navigation with Turbo Drive
- Dynamic page updates with Turbo Frames
- How Turbo Frames Work?
- Step One: Wrap Component in Turbo Frame
- Step Two: Wrap Response in a Turbo Frame with same ID
- Working with Turbo Streams
- Target Multiple Elements with Turbo Streams
- Step One: Use Turbo HTTP Header
- Step Two: Send Turbo Streams in Response
- Where to go from here
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.
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?
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.
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.
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:
- Prevent the browser from following the link,
- Change the browser URL using the History API,
- Request the new page using a
fetch
request - 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.
Now go ahead and click back and forth between the Home and Contact links.
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.
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.
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.
- Turbo intercepted that click, prevented the browser from following it, made a
fetch
request to get the content of the Contact page. - Upon receiving the HTTP response, Turbo then replaced the current body of the web page with the body of the result.
- 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.
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.
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.
I will demonstrate Turbo Frames by building a simple gallery on our home page. Here's how the resulting page will look.
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.
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
.
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.
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:
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.
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:
- replace: to swap the existing content of an element with the ID
subscriber-notification
with the content inside the<template>
tag. - append: to append the contents inside the
<template>
tag to the element with the IDsubscriber-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.
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:
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.