Blog
Post

Faster Astro Navigation Without Turbo

I added Hotwire Turbo to make Astro internal links feel instant without hurting PageSpeed 100. Deferred boot broke the first mobile tap, so I deleted Turbo and shipped Astro ClientRouter + prefetch instead.

By
Diego Menchaca
Jul 21, 2026
7 min read
Share:
Turbo Drive was my first pass at SPA-like Astro navigations. Intent-deferred boot kept Lighthouse at 100 but missed the first mobile tap. Astro ClientRouter + hover prefetch gave me the same result with less code, so Turbo came out.

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
// 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:

Skip prefetch on constrained networks
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.

Document-level navbar binding
// 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:

BaseLayout.astro
---
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
// 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:

Navbar hooks after ClientRouter
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:

Plausible SPA pageviews
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:

  1. ClientRouter intercepts same-origin navigations and swaps the document without a full reload
  2. Astro prefetch warms the next page on hover/touch, including the mobile touchstart path my deferred Turbo boot missed
  3. No extra ~25KB vendor chunk on the critical path, and no custom intent-boot script to keep PageSpeed green
  4. 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.