September 9, 2026
Astro islands for light reactivity: zero JavaScript where none is needed
The micrajs.dev site runs on Astro. Most of it is plain content: docs pages, blog posts, a landing page. There are exactly three places that need to react to the user. A hamburger menu in the header, which exists only on narrow screens. A live counter in the hero of the homepage. And a waitlist form on the Pro page. Everything else is HTML the server rendered once at build time.
Astro is built for this shape of site. It ships no JavaScript by default, and interactivity is added in islands: you mark a piece of the page with a strategy like client:visible, and that piece loads its own code when the strategy fires. The rest of the page stays static.
I wanted the small reactivity on my site to follow the same rules. Each interactive spot should load only its own code, only when it is actually needed, and a page with no such spots should load nothing. That is what the @micrajs/astro integration does for Micra, a small library that adds reactivity to HTML the server already rendered. This post is about how the site uses it and how to set it up well.
An island is a file plus a slot
A component is one file under src/micra/. It exports a Micra definition and nothing else:
// src/micra/counter.ts
import { defineComponent } from 'micra.js'
export default defineComponent({
state: { count: 0 },
inc() { this.state.count++ },
dec() { this.state.count-- },
})
The markup lives in the page, as the slot of the <Micra> component. Astro renders it on the server, so it is visible before any script arrives:
---
import Micra from '@micrajs/astro/Micra.astro'
---
<Micra name="counter" client="visible">
<button @click="dec">−</button>
<span data-text="count">0</span>
<button @click="inc">+</button>
</Micra>
The name points at the file. The client prop says when the island wakes up. When it does, a small loader fetches the Micra core and the counter chunk and mounts the component on that element. Until then the counter is static HTML showing 0.
Under the hood the integration collects the component files with import.meta.glob, and Vite turns each file into its own chunk. This is what makes the per-island loading real. A page with three different islands makes three small requests, each at its own moment. A page with no islands makes none.
Choosing a strategy
The client prop takes four values:
| Strategy | When the island hydrates |
|---|---|
load |
Right away on page load |
idle |
When the browser has a free moment |
visible |
When the island scrolls into the viewport |
media:(query) |
When a media query matches |
The choice follows from what the island is for. Something the user will interact with immediately, like a cart badge in the header, wants load. Something further down the page, like a demo or a form, wants visible, and the user will never notice the delay. idle fits things that are nice to have and not urgent. media fits anything that exists only on some screen sizes.
If you leave client off, the island uses defaultClient from the integration options. It is load unless you change it.
The three islands on micrajs.dev
The mobile menu. The header has a row of links and a hamburger button. Above 860px the links are visible and the button is hidden by CSS. Below 860px the links hide and the button toggles a menu. So the header is interactive only on narrow screens, and the island says exactly that:
<Micra name="mobilenav" client="media:(max-width: 860px)" as="div" class="mnav">
<div class="bar">
<a class="brand" href="/">Micra<span>.js</span></a>
<nav class="nav"><NavLinks /></nav>
<button
type="button"
class="menu-toggle"
@click="toggle"
data-class="open:open"
data-bind="aria-expanded:open ? 'true' : 'false'"
aria-controls="mobile-menu"
>
<span></span><span></span><span></span>
</button>
</div>
<div class="mobile-menu" id="mobile-menu" data-show="open" style="display:none">
<nav><NavLinks /></nav>
</div>
</Micra>
The media strategy uses matchMedia. On a phone the query matches, the island hydrates, the button works. On a desktop the query does not match, and the page loads no Micra at all: no core, no menu chunk. The header still works, because the links are ordinary links in server HTML. Resize the window down to mobile width and the query starts matching, the code arrives, the button comes alive.
This is the case that made me want the integration in the first place. A typical docs page on the site has one interactive element, and that element is invisible on desktop. With a global script the runtime would arrive on every page anyway. With the island it arrives only on the screens where the menu exists.
The counter. The homepage hero has a live counter next to the code sample. It sits in the first screen, so visible fires almost immediately. It still gets visible because on a phone the hero is taller and the counter may start below the fold.
The waitlist form. The Pro page has an email form. It uses visible, and it needs to know the API endpoint. That is passed as a prop:
<Micra name="waitlist" client="visible" props={{ endpoint }}>
<form action={endpoint} method="POST" @submit.prevent="submit">
<input type="email" name="email" data-model="email" required>
<button type="submit">Join the waitlist</button>
</form>
</Micra>
Props are serialized to data-* attributes and read back inside the component with this.prop('endpoint'). They are scalars: strings, numbers, booleans. If an island needs structured data, the Micra way is to fetch it in onCreate. Keeping props scalar keeps the markup readable and keeps the component in charge of its own data.
The form also carries a plain action and method. Before the island hydrates, or if it never does, submitting the form still sends the email to the server the old-fashioned way.
What the build shows
Here are the island chunks from the production build of the site:
| Chunk | Size |
|---|---|
| counter | 0.16 KB |
| waitlist | 0.64 KB |
| mobilenav | 0.75 KB |
| Micra core | 7.98 KB gzip |
The core is a separate chunk and it is also lazy. It downloads the first time an island on the page actually hydrates. A desktop docs page never triggers that, so it downloads nothing.
The heavy case shows the same thing more clearly. The integration’s example gallery has an island with a chart.js chart. Its chunk is about 207 KB. Pages without the chart never see those bytes. They ride only in the chart island’s chunk, and only when that island scrolls into view.
Setting it up well
The setup itself is two steps. Install the packages and add the integration to the config:
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 few habits make islands pleasant to live with.
Make the markup correct before hydration. The slot is what the user sees until the island wakes up, and on some pages that is forever. Put the initial value in the element, like the 0 inside the counter’s data-text span. Give hidden blocks an inline style="display:none" so the mobile menu does not flash open on load. Let the links in the header be real links. If the island never hydrates, the page should still look and work like a finished page.
One component per file, named after the file. The name on <Micra> is the file name. A typo is caught at build time, and the error lists the components that exist. This is a nice property to have with a dozen islands spread over many pages.
Clean up document-level listeners. The mobile menu closes on Escape and on a click outside, so it attaches listeners to document in onCreate. It removes them in onDestroy. With Astro view transitions enabled, islands on the page you leave are torn down automatically, and a forgotten listener would keep firing on the next page.
onCreate() {
this._onKeydown = (e) => { if (e.key === 'Escape') this.close() }
document.addEventListener('keydown', this._onKeydown)
},
onDestroy() {
document.removeEventListener('keydown', this._onKeydown)
},
Persist state that should survive navigation. With <ClientRouter /> on the page, an island marked transition:persist keeps its element and its Micra state across a client-side navigation. A cart counter in the header is the usual example.
Use the entrypoint for one-time setup. Bus listeners, sanitizer config, anything that should run once before the first island mounts goes into a module you point the integration at with micra({ entrypoint: '/src/micra.setup.ts' }). It receives the Micra namespace and runs once.
Check the dev toolbar. In development Astro’s toolbar gets a Micra panel that lists the islands on the current page with their strategy and whether they have hydrated. It is the fastest way to confirm that the menu island really is waiting on its media query.
When a page should skip islands
If a page is interactive almost everywhere, splitting it into islands is ceremony for no gain. Load the core with a plain <script> tag and call Micra.start(), the same way you would on any server-rendered page. The integration is for the other shape: a mostly static page with a few live spots, sometimes conditional ones like the menu. That happens to be the typical Astro page.
And if a widget needs a whole ecosystem, a component tree, a form library, shared client state across routes, Astro’s native React, Vue or Svelte islands are the right tool. Micra is the small option for the small case.
The site itself is the proof I trust most. Three islands, each a few hundred bytes, each arriving only where it has work to do. The docs pages ship the HTML and nothing else.
The integration, its example gallery and the full option list are documented at micrajs.dev/docs/recipes/astro.