Astro + Micra.js

Astro renders your pages to HTML and ships no JavaScript by default. The @micrajs/astro integration adds interactivity the same way Astro does everything else — per island. You wrap a piece of server-rendered markup in <Micra>, and it becomes interactive on the strategy you choose, pulling only its own component as a separate chunk. A page with no islands ships no Micra at all.

Install

npm install @micrajs/astro micra.js
// astro.config.mjs
import { defineConfig } from 'astro/config'
import micra from '@micrajs/astro'

export default defineConfig({
  integrations: [micra()],
})

A component and an island

A component is one file under src/micra/, default-exporting a Micra definition. This file is the only JavaScript for that island, and Astro code-splits it into its own chunk.

// src/micra/counter.ts
import { defineComponent } from 'micra.js'

export default defineComponent({
  state: { count: 0 },
  inc() { this.state.count++ },
  onCreate() { this.state.count = this.prop('start', 0) as number },
})

Drop it on any page with <Micra>. The slot is the template — rendered on the server, visible with no JavaScript — and the name maps to the file:

---
import Micra from '@micrajs/astro/Micra.astro'
---
<Micra name="counter" client="visible" props={{ start: 3 }}>
  <button @click="inc">+</button>
  <span data-text="count"></span>
</Micra>

The counter chunk is fetched only when the island scrolls into view. A page that never shows it never loads it.

Hydration strategies

The client prop decides when an island wakes up:

Strategy When it hydrates
load Immediately on page load
idle When the browser goes idle
visible When the island scrolls into the viewport
media:(query) When a media query matches, e.g. media:(max-width: 600px)

Omit client and the island uses the integration’s defaultClient (load unless you change it).

Props

props are serialized to data-* attributes and read back with this.prop():

<Micra name="user-card" client="load" props={{ userId: 42, plan: 'pro' }} />
onCreate() {
  const id = this.prop('userId')          // 42 (number)
  this.state.plan = this.prop('plan', 'free')
}

Props are scalars (string / number / boolean), mirroring this.prop(). Structured or initial data belongs in this.fetch() inside onCreate, not in an attribute.

Nested islands

An island’s slot can hold other islands. Each hydrates independently on its own strategy — Micra treats a nested data-component as a boundary, so an outer component never binds a child’s directives.

<Micra name="panel" client="load">
  <button @click="toggle">toggle</button>
  <Micra name="counter" client="visible" props={{ start: 5 }}>
    <button @click="inc">+</button>
    <span data-text="count"></span>
  </Micra>
</Micra>

View transitions

With Astro’s <ClientRouter />, islands re-arm after each navigation, and components on the page you leave are torn down automatically (their onDestroy runs). Mark an island transition:persist to keep it across a navigation — the same element, and its Micra state, survives the swap:

<Micra name="cart" client="load" transition:persist>
  <span data-text="count"></span>
</Micra>

Global setup

Run code once before the first island mounts — register bus listeners, turn on data-html sanitizing — with an entrypoint module:

// astro.config.mjs
micra({ entrypoint: '/src/micra.setup.ts' })
// src/micra.setup.ts
import type * as Micra from 'micra.js'

export default (micra: typeof Micra) => {
  micra.config({ sanitize: DOMPurify.sanitize })
  micra.on('cart:add', () => {/* … */})
}

Options

micra({
  componentsDir: '/src/micra', // where component files live (default)
  defaultClient: 'load',       // strategy for <Micra> with no `client`
  rootMargin: '0px',           // visible strategy: hydrate this far before the viewport
  observe: false,              // also arm islands inserted after load (innerHTML, etc.)
  entrypoint: undefined,       // module run once before the first mount
})

A mistyped name is caught at build time with the list of available components. In dev, the Micra panel in Astro’s toolbar lists the islands on the page with their strategy and hydration state.

Without the integration

If you want Micra across a whole page rather than per island — a small content site where nearly everything is interactive — you don’t need the integration at all. Load the core from a CDN and mount it yourself, wiring the two view-transition lifecycle points by hand:

---
// 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()                                            // teardown on navigation away
      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, and Micra.start() is idempotent, so one listener covers both. The integration does exactly this wiring for you, per island, and adds the code-splitting.

When Astro’s own islands fit better

This integration is for a lightweight island where you’d otherwise ship a whole framework for one widget. If you need full component islands with their own ecosystem — a component tree, form libraries, shared client state across routes — Astro’s native React, Vue, or Svelte islands are the right tool. Micra is the small option, not a replacement for them.