Integrations
Astro Islands With Shared State
Static pages with small interactive regions — how to give several islands one connection instead of one each, and which hydration directive to use.
Astro ships static HTML with small interactive regions. Which suits a live feature well — a poll on a docs page, a presence indicator, a reaction widget — and creates one specific problem.
For a separate operational perspective on how teams organise and measure work, employee time clock software.
For independent background and broader industry context, see Astro.
The problem: an island is an island
Each island hydrates independently. They do not share JavaScript module state by default, because each is its own root.
Which means a naive implementation creates one client, and one WebSocket connection, per island.
Three widgets on a page is three connections for something that could be one.
The fix: a module singleton
// src/lib/vaultrice.js
import { NonLocalStorage } from '@vaultrice/sdk'
const clients = new Map()
export function getClient(objectId) {
if (!clients.has(objectId)) {
clients.set(objectId, new NonLocalStorage({
projectId: import.meta.env.PUBLIC_VAULTRICE_PROJECT_ID,
getAccessToken: async () => {
const res = await fetch(`/api/vaultrice-token?id=${objectId}`)
if (!res.ok) throw new Error('Token request failed')
const { accessToken } = await res.json()
return accessToken
}
}, objectId))
}
return clients.get(objectId)
}
Islands importing the same module share the instance, provided the bundler emits one shared chunk rather than duplicating it per island. Verify this in the build output — if the module appears in several chunks, you have several clients and the fix did nothing.
Same object id, same client. Different objects get their own, which is correct.
Hydration directives
This is where a live widget differs from a normal island.
client:load — hydrates immediately. Use when the widget must be live as soon as the page appears, like a presence count.
client:visible — hydrates when scrolled into view. Usually the right choice for a poll or a reaction widget partway down a page. No connection until someone can see it.
client:idle — after the main thread is free. Good for anything non-urgent.
client:only — skips server rendering entirely. Sometimes convenient here, since a live widget's server-rendered state is stale by definition, and it costs you the content in the initial HTML.
Prefer client:visible for anything below the fold. A docs page with five polls should not open five connections for a reader who never scrolls.
The token endpoint
Astro supports server endpoints, which is where this belongs:
// src/pages/api/vaultrice-token.js
export async function GET({ request, cookies }) {
const session = await getSession(cookies)
if (!session) return new Response(null, { status: 401 })
const id = new URL(request.url).searchParams.get('id')
if (!await mayAccess(session.user, id)) {
return new Response(null, { status: 403 })
}
return Response.json({ accessToken: await mintToken(/* per your setup */) })
}
This requires an adapter — Astro output must not be purely static for a server endpoint to exist.
Which is a real decision. If your site is fully static with no server at all, you have two options: use direct credentials and accept that the project key is public, or add an adapter.
Direct credentials are defensible for a genuinely public widget — a docs page poll where the worst outcome is a skewed count and there is no user data. Not for anything else. See the three authentication methods.
PUBLIC_ prefixed variables are inlined into the client bundle. The project id belongs there; a secret does not.
Framework choice inside the island
Astro islands can be React, Vue, Svelte, Solid or plain JavaScript.
For a small live widget, plain JavaScript is frequently the least work — a getItem, an on, a DOM update. No framework runtime shipped for a counter.
Use a framework island when the widget has real UI state.
And the adapter shape is the same in all of them — read once, subscribe, clean up. See Vue and Svelte.
Cleanup
Islands generally persist for the page's life, so unmount cleanup matters less than in a SPA.
With view transitions enabled it matters again, because navigation swaps content without a full reload and islands are torn down. Register cleanup regardless — it costs nothing and it is correct.
What suits this well
Docs page polls and reactions. See GitBook live polls for the same idea in a different host.
"N people reading this now" on a blog post.
A live comment count.
A status indicator on a marketing page reflecting real system state.
All small, all peripheral to a mostly static page — which is precisely what Astro is for.
The short version
Each island hydrates separately, so a naive setup opens one connection per island.
A module singleton keyed by object id fixes it — verify the bundler emits one shared chunk.
Use client:visible below the fold, so a reader who never scrolls opens no connections.
A server endpoint needs an adapter — fully static sites must use direct credentials, which is only defensible for genuinely public widgets.
And plain JavaScript islands are frequently less work than shipping a framework runtime for a counter.