Recipe: Astro + Micra.js

Micra fits Astro as a small reactive island — a bit of interactivity on a content page without pulling a framework for one widget. It’s the slot @astrojs/alpinejs fills, with a smaller surface. Astro renders the HTML; Micra hydrates the [data-component] markup in the browser.

Static pages (no view transitions)

Nothing special. Add the script, write the markup, call Micra.start().

---
// src/layouts/Base.astro
---
<html lang="en">
  <head>
    <script is:inline src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>
  </head>
  <body>
    <slot />
    <script is:inline>
      Micra.define('counter', { state: { count: 0 }, inc() { this.state.count++ } })
      Micra.start()
    </script>
  </body>
</html>
<!-- any .astro page -->
<div data-component="counter">
  <button @click="inc">+</button>
  <strong data-text="count"></strong>
</div>

With view transitions (<ClientRouter />)

When you enable Astro’s client-side routing, navigation swaps the <body> without a full reload. Two things follow: components on the new page need mounting, and components on the page you left need tearing down. Micra handles both — mount on astro:page-load, teardown via Micra.autoCleanup() (v2.7+).

---
// src/layouts/Base.astro
import { ClientRouter } from 'astro:transitions'
---
<html lang="en">
  <head>
    <ClientRouter />
    <script is:inline src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>
    <script is:inline>
      // The <head> is not swapped by view transitions, so this runs once.
      Micra.define('counter', { state: { count: 0 }, inc() { this.state.count++ } })

      Micra.autoCleanup() // destroy components on pages you navigate away from
      document.addEventListener('astro:page-load', () => Micra.start()) // mount each page
    </script>
  </head>
  <body>
    <slot />
  </body>
</html>

astro:page-load fires on the initial load and after every client-side navigation, so a single listener covers both. Micra.start() is idempotent — re-scanning is safe; already-mounted roots are skipped. Going the other way, autoCleanup() watches the DOM and destroys a component when Astro swaps its element out, running onDestroy and freeing its listeners.

Put Micra.define(...) and autoCleanup() in the <head> script (it isn’t swapped, so it runs once); keep only Micra.start() on the astro:page-load event.

Persisted islands

If you keep an island across navigations with transition:persist, its DOM isn’t removed — so autoCleanup() leaves it alone and its Micra state survives the navigation. The next astro:page-load calls Micra.start(), which skips it (already mounted). Persistence just works, no extra wiring.

<aside data-component="sidebar-filter" transition:persist>
  <!-- state.query and friends survive page navigations -->
</aside>

When to reach for Astro’s own islands instead

This recipe is for a lightweight island where you’d otherwise ship a whole framework for one widget. If you want full component islands with their own ecosystem — forms, state libraries, a component tree — Astro’s native React/Vue/Svelte islands are the better tool. Micra is the small option, not a replacement for them.