After migrating diego.bio from TanStack Start to Astro, the site felt fast on first load. Lighthouse Performance sat at 100 on mobile. But clicking between Home, Writings, and a post still felt like a classic multi-page app. Full document navigations. White flashes. Navbar scripts re-binding. Fine for a brochure site. Wrong for something I open every day.
So I set out to make internal links feel instant. The interesting part is not the final code. It is that my first strategy was Hotwire Turbo, and by the end I deleted it entirely, because Astro already ships the thing I needed.
The goal
I wanted three things at once:
- Internal navigations should swap content without a full page reload
- Hover/tap should prefetch the next page so the click feels free
- Mobile PageSpeed on `/` stays at 100, with no large JS tax on first paint
That is it. No view-transition theatre. No client-side router rewriting the URL model. Just: click an internal link, get the next page now.
Strategy 1: Hotwire Turbo Drive
If you come from the Rails / Hotwire world, Turbo Drive is the obvious hammer. Intercept same-origin link clicks, fetch the HTML, swap the <body>, update the URL, keep the browser history sane. Works with server-rendered pages. No React Router. No framework lock-in.
Astro is HTML-first. Turbo is HTML-first. On paper they pair cleanly: Astro renders pages, Turbo makes moving between them feel like an SPA.
So I added @hotwired/turbo and wired it from BaseLayout.astro.
The PageSpeed constraint: deferred Turbo boot
Turbo is roughly ~25KB gzipped. That is nothing compared to a typical React hydration bundle, and everything when you are guarding a 100 Lighthouse score on a content site that currently ships almost no JS on first paint.
Lighthouse / PageSpeed Insights measure the initial load. They do not hover your nav links. So I deferred Turbo until the user showed intent to navigate:
// turbo-boot.ts
let booted = false
let booting = false
async function bootTurbo() {
if (booted || booting) return
booting = true
try {
await import('@hotwired/turbo')
registerPrefetchGuard()
booted = true
} finally {
booting = false
if (booted) removeIntentListeners()
}
}
function onIntent(event: Event) {
const anchor = (event.target as Element | null)?.closest?.('a')
if (!(anchor instanceof HTMLAnchorElement)) return
if (!isInternalNavigableLink(anchor)) return
void bootTurbo()
}
document.addEventListener('pointerdown', onIntent, true)
document.addEventListener('focusin', onIntent, true)
document.addEventListener('mouseover', onIntent, true)First paint only downloads a tiny boot script. The Turbo chunk loads after pointerdown, focusin, or mouseover on an internal link. PageSpeed stays at 100. Hover a link, Turbo boots, and later Drive visits feel instant.
I also skipped hover prefetch on Save-Data and slow connections:
function isSlowConnection() {
const connection = navigator.connection
if (!connection) return false
if (connection.saveData) return true
return connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g'
}
document.addEventListener('turbo:before-prefetch', (event) => {
if (isSlowConnection()) event.preventDefault()
})Then the SPA tax showed up
The moment you stop doing full reloads, every piece of page JS becomes a lifecycle problem. Scripts that bound click handlers to the hamburger on DOMContentLoaded now bind to nodes that Turbo is about to discard.
That bit me immediately: after a Turbo visit, the mobile menu would stop opening. Classic Drive footgun. The fix was the same one every MPA-to-Drive migration eventually lands on: document-level delegation, a native button for the toggle, and closing the menu before Turbo caches or renders the next page.
// SiteNavbar.astro (simplified)
let bound = false
function bindSiteNavbar() {
if (bound) return
bound = true
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown)
// Close before Turbo snapshots / swaps an open drawer
document.addEventListener('turbo:before-cache', closeAllNavbars)
document.addEventListener('turbo:before-render', closeAllNavbars)
}
function onDocumentClick(event: MouseEvent) {
const target = event.target
if (!(target instanceof Element)) return
const toggle = target.closest('[data-site-nav-toggle]')
if (toggle) {
const root = toggle.closest('[data-site-navbar]')
if (root) setNavbarOpen(root, !root.classList.contains('w--open'))
}
}
bindSiteNavbar()It worked. It was also a smell: I was spending cycles teaching the site how to survive a library I added to avoid full reloads.
The dealbreaker: lazy boot missed mobile taps
Deferred boot protected PageSpeed. It also created a race on the first mobile navigation.
On desktop, mouseover usually boots Turbo before the click. On mobile, the first gesture is often the tap itself. If Turbo has not finished importing by the time the browser follows the link, you get a hard navigation: exactly the full reload I was trying to avoid. The second tap felt fast. The first one did not. That is the opposite of what you want when you are selling "instant".
Strategy 2: Astro ClientRouter + prefetch
Astro already has view transitions / ClientRouter. I had been treating it as an animation feature. It is also a same-document navigation layer with prefetch built in.
So I deleted Turbo and flipped on the native path:
---
import { ClientRouter } from 'astro:transitions'
---
<html lang="en" transition:animate="none">
<head>
{/* Instant DOM swap; swap fallback without the VT API */}
<ClientRouter fallback="swap" />
...
</head>
</html>// astro.config.mjs
export default defineConfig({
prefetch: {
prefetchAll: true,
// mouseenter/focus on desktop, touchstart on mobile
defaultStrategy: 'hover',
},
})A few details mattered:
- transition:animate="none": I wanted an instant DOM swap, not a fade that advertises the transition
- fallback="swap": browsers without the View Transitions API still get the fast swap path
- hover prefetch: desktop gets the page on mouseenter; mobile gets it on touchstart, which is early enough to matter
Navbar lifecycle hooks moved from turbo:* to Astro events. Same delegation pattern, fewer dependencies:
function bindSiteNavbar() {
if (bound) return
bound = true
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onDocumentKeydown)
document.addEventListener('astro:before-preparation', closeAllNavbars)
document.addEventListener('astro:after-swap', closeAllNavbars)
}Plausible only auto-tracks the first load, so I fire a pageview after ClientRouter swaps:
document.addEventListener('astro:after-swap', () => {
if (typeof window.plausible === 'function') {
window.plausible('pageview')
}
})Why Turbo wasn't needed
Turbo is excellent software. It was just solving a problem Astro already solves in-tree:
- ClientRouter intercepts same-origin navigations and swaps the document without a full reload
- Astro prefetch warms the next page on hover/touch, including the mobile touchstart path my deferred Turbo boot missed
- No extra ~25KB vendor chunk on the critical path, and no custom intent-boot script to keep PageSpeed green
- Lifecycle events are first-class (astro:after-swap, etc.), so islands, analytics, and the navbar rebind against a documented contract instead of a third-party Drive event map
The deferred-boot trick that made Turbo PageSpeed-safe is what made it feel broken on the first mobile tap. Astro's prefetch strategy does not need that trick: the framework owns both routing and prefetch, so the mobile path is coherent by default.
I still kept the hard-won navbar lesson. Document-level delegation is correct whether the swap comes from Turbo, ClientRouter, or anything else that replaces the body. That fix survived the delete.
What shipped
End to end, the path looked like this:
- Add Turbo Drive with interaction-deferred boot (PageSpeed 100 preserved)
- Keep the mobile hamburger alive across Drive swaps
- Remove Turbo; enable ClientRouter + global hover prefetch
End state: internal links prefetch on intent, navigations swap without a full reload on desktop and mobile, the menu still works, Plausible still counts SPA navigations, and the homepage still scores 100 because I never paid for a Drive runtime on first paint.
Takeaways
- Reach for framework-native navigation before bolting on Turbo/htmx/PJAX, especially on Astro, where ClientRouter + prefetch already cover the MPA-to-feel-like-SPA gap
- If you defer loading a router to protect Lighthouse, measure the first mobile tap. Desktop hover hides races that phones will show you immediately
- The real cost of client-side navigation is rarely the library size. It is every script that assumed a full reload: nav toggles, analytics, anything bound to specific DOM nodes
- Prefer document-level delegation for UI that must survive swaps. Per-node listeners die with the snapshot
Turbo was the right first experiment. Deleting it was the right second one. The fastest navigation code is the code Astro was already willing to run for you.