In my previous article, we saw how to transform a static Astro site into an ultra-smooth Single Page Application (SPA) with a single line of code: <ClientRouter />.

The “Wow” effect is immediate. Your pages chain together with smooth transitions, it’s beautiful. Then, you test your site on mobile… and disaster strikes.

You open your hamburger menu, you click a link. The new page loads in the background, the transition occurs, but your mobile menu remains wide open right in the middle of the screen. Even worse: if you close it and try to open it again, the button no longer responds!

What happened? Welcome to the wonderful world of the View Transitions lifecycle.

Why is your menu broken?

To understand the bug, you have to understand how Astro’s <ClientRouter /> works.

During a classic navigation (MPA), the browser destroys the old page, downloads the new one, and executes your JavaScript from scratch. The DOMContentLoaded event fires, and your script nicely attaches an addEventListener to your menu button.

With the ClientRouter, there is no more full reload. Astro intercepts the click, fetches the new HTML, and only replaces (swaps) the content of the <body> tag.

This creates two major problems for our mobile menu:

  1. Loss of event listeners: The old hamburger button was destroyed and replaced by a new one. But your script, which only ran on the first load, is still listening to the “ghost” of the old button.
  2. State persistence (CSS): If your menu opens by adding a .menu-open class on the <body>, this class doesn’t magically disappear during the transition. The new content arrives, but the body keeps the order to stay open!

The solution: Adapting to the new lifecycle

Astro anticipated this and provides custom events to replace our old habits.

Step 1: Replace DOMContentLoaded

Forget DOMContentLoaded. For your script to run both on the first load and after every smooth navigation, you must use the astro:page-load event.

// ❌ The old way (only works once)
document.addEventListener('DOMContentLoaded', () => {
  initMenu();
});

// ✅ The Astro way (runs on every page change)
document.addEventListener('astro:page-load', () => {
  initMenu();
});

Step 2: The complete and robust script

Now that we know when to execute our code, we must ensure it does two things: attach the event to the new button, and force the default menu closure.

Here is an example of a mobile menu script (Vanilla JS) perfectly adapted for the ClientRouter:

<script>
  function initMobileMenu() {
    // 1. Fetch the freshly injected DOM elements
    const burgerBtn = document.getElementById('burger-menu');
    const mobileNav = document.getElementById('mobile-nav');
    
    // 2. Secure it: if elements don't exist on this page, stop
    if (!burgerBtn || !mobileNav) return;

    // 3. Force default closure (fixes the persistent menu bug)
    document.body.classList.remove('menu-open');
    burgerBtn.setAttribute('aria-expanded', 'false');

    // 4. (Re)attach the click event to the button
    // Note: We use a clean arrow function to avoid 
    // stacking listeners if the logic gets complex.
    burgerBtn.addEventListener('click', () => {
      const isOpen = document.body.classList.contains('menu-open');
      
      if (isOpen) {
        document.body.classList.remove('menu-open');
        burgerBtn.setAttribute('aria-expanded', 'false');
      } else {
        document.body.classList.add('menu-open');
        burgerBtn.setAttribute('aria-expanded', 'true');
      }
    });
  }

  // The magic Astro event
  document.addEventListener('astro:page-load', initMobileMenu);
</script>

Even with this fix, there’s a small UX detail left. If the user clicks a link inside your mobile menu, the page will transition, the menu will reset (thanks to our step 3)… but sometimes the transition can look choppy if the menu stays open during the page change animation.

You can use another Astro event, astro:before-preparation (which fires as soon as the link is clicked, even before fetching the new page), to force the immediate closure of the menu, offering an even smoother transition.

document.addEventListener('astro:before-preparation', () => {
  document.body.classList.remove('menu-open');
});

By mastering these events (astro:page-load and astro:before-preparation), you now have all the keys in hand to tame the <ClientRouter /> and offer your users flawless navigation!