Turbolinks and Hotwire — SPA-Like Navigation Without JavaScript Frameworks
In this tutorial, you will learn about Turbolinks and Hotwire. We cover key concepts, practical examples, and best practices to help you master this topic.
Turbolinks and Hotwire bring SPA-like instant navigation to MPAs by intercepting clicks, fetching HTML via AJAX, and replacing the page body without a full browser reload.
What You'll Learn
By the end of this tutorial, you will understand how Turbolinks (now part of Hotwire) works, how it intercepts navigation and replaces page content, how to handle JavaScript lifecycle events, progressive enhancement principles, and when to use Hotwire over a full SPA framework.
Why It Matters
MPAs suffer from full-page reloads that feel slow and flash white. Turbolinks eliminates the flash by fetching only the body HTML and replacing it in-place, while keeping the CSS and JavaScript context alive. This gives SPA-like performance without writing client-side JavaScript frameworks.
Real-World Use
Basecamp, the project management tool that created Hotwire, uses it for their entire application. Users get instant navigation without page flashes, forms submit without full reloads, and the application remains server-rendered. Basecamp serves millions of users with this architecture.
How Turbolinks Works
┌──────────┐ ┌──────────┐
│ Browser │ │ Server │
└────┬─────┘ └────┬─────┘
│ │
│ Normal MPA: │
│ Click link │
│ Full page reload │
│ CSS/JS re-executed │
│ Page flashes │
│ │
│ With Turbolinks: │
│ Click link │
│ ───────────────────────>│
│ Fetches HTML via fetch │
│ (no full reload) │
│ │
│ HTML body │
│ <───────────────────────│
│ │
│ Replaces <body> │
│ Merges <head> │
│ Updates URL (pushState) │
│ No flash! │
│ CSS/JS stays loaded │
│ │
│ Progress bar shown │
│ during navigation │
└──────────────────────────┘
Think of Turbolinks like changing the channel on a TV versus turning the TV off and on. Normal MPA navigation turns the TV off, waits, and turns it back on — you see a black screen and the startup logo. Turbolinks just changes the channel — the TV stays on, and the new content appears instantly.
Installing and Using Turbolinks
<!-- Include Turbolinks from CDN or npm -->
<script src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@7/dist/turbo.min.js"
defer></script>
<!-- Full page layout -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My App</title>
<link rel="stylesheet" href="/styles.css">
<!-- Turbolinks tracks CSS changes -->
<meta name="turbo-cache-control" content="no-cache">
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
<main id="content">
<!-- Turbolinks replaces this section -->
<%= yield %>
</main>
<script src="/app.js" defer></script>
</body>
</html>
<!-- Expected behavior:
- Click on /products
- URL changes to /products
- Body content replaces without flash
- Console: "Turbo: navigating to /products"
- Progress bar appears briefly
- No CSS/JS re-execution (unless changed in <head>) -->
Hotwire Lifecycle Events
// Listen for Turbolinks lifecycle events
document.addEventListener('turbo:before-visit', (event) => {
console.log('Navigation starting:', event.detail.url);
// Cancel navigation if needed:
// event.preventDefault();
});
document.addEventListener('turbo:before-render', (event) => {
console.log('About to render new page');
// Add animations before render
event.detail.newBody.style.opacity = '0';
});
document.addEventListener('turbo:render', () => {
console.log('Page rendered, re-initializing components');
// Re-initialize JavaScript components
initializeCarousels();
initializeForms();
updateNavigationActiveState();
});
document.addEventListener('turbo:load', () => {
console.log('Turbo navigation complete');
// Equivalent to DOMContentLoaded for Turbo navigations
});
document.addEventListener('turbo:before-cache', () => {
console.log('Page being cached for back navigation');
// Clean up listeners, pause animations
});
// Turbo Drive replaces Turbolinks in Hotwire 7
document.addEventListener('turbo:frame-missing', (event) => {
event.preventDefault();
console.log('Turbo frame target missing, loading full page');
window.location.href = event.detail.response.url;
});
Turbo Frames and Streams
<!-- Turbo Frame — lazy load a portion of the page -->
<turbo-frame id="cart-count" src="/cart/count">
<!-- Loading state shown initially -->
<span>Loading cart...</span>
</turbo-frame>
<!-- Server responds with matching frame -->
<!--
<turbo-frame id="cart-count">
<span>3 items ($89.97)</span>
</turbo-frame>
-->
<!-- Turbo Streams — update page after form submission -->
<form action="/cart/add" method="post">
<input type="hidden" name="product_id" value="123">
<button type="submit">Add to Cart</button>
</form>
<!-- Server responds with Turbo Stream:
Content-Type: text/vnd.turbo-stream.html
<turbo-stream action="replace" target="cart-count">
<template>
<turbo-frame id="cart-count">
<span>4 items ($124.96)</span>
</turbo-frame>
</template>
</turbo-stream>
<turbo-stream action="append" target="flash-messages">
<template>
<div class="flash success">Item added to cart!</div>
</template>
</turbo-stream> -->
Common Mistakes
- Not re-initializing JavaScript after navigation. Turbolinks replaces the body. JavaScript that runs on DOMContentLoaded does not run again. Use turbo:load or turbo:render events to re-initialize components.
- Memory leaks from event listeners. Adding event listeners without removing them causes memory leaks on cached pages. Remove listeners in turbo:before-cache.
- Using Turbolinks for everything. Some pages benefit from full reloads (payment gateways, file uploads). Exclude specific links with data-turbo="false".
- Breaking the back button. Turbolinks restores cached pages instantly. Ensure that JavaScript state is properly saved and restored for the back button to work correctly.
- Not handling form submissions with Turbo Streams. Without Turbo Streams, form submissions cause full page reloads. Use Turbo Streams to update only the changed parts of the page.
Practice Questions
- How does Turbolinks achieve faster navigation compared to standard MPA?
- What events does Turbolinks fire during navigation?
- Why do you need to re-initialize JavaScript components after Turbolinks navigation?
- What are Turbo Frames and how do they enable partial page updates?
- How do Turbo Streams handle form submissions without page reload?
Challenge: Convert a standard MPA to use Hotwire: add Turbolinks for instant navigation, implement a Turbo Frame for the shopping cart that updates on add/remove, use Turbo Streams for form submissions (add to cart, delete item) without page reload, add a progress bar, and properly re-initialize JavaScript after navigation.
FAQ
Mini Project
Convert a 5-page blog MPA to use Hotwire: add Turbo Drive for instant navigation between pages, implement a Turbo Frame for the comments section that lazy-loads and allows inline adding of comments via Turbo Streams, add a progress bar for navigation, re-initialize syntax highlighting after page changes, and ensure the back button works correctly with cached pages.
What's Next
You understand Turbolinks and Hotwire. Now explore HTMX for MPAs to add dynamic behavior with HTML attributes instead of JavaScript.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro