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.