DEV Community

knot crochet
knot crochet

Posted on Originally published at autonnel.com

I Built Runtime Islands for Astro Because My Pages Live in a Database

Astro's island model has one assumption baked into it: you know at build time which components need JavaScript. You write client:load on a component in a .astro file, the compiler sees it, and it emits exactly the right script tag.

I don't know that at build time. The pages my app renders are drag-and-drop pages. A user arranges blocks in a visual builder, and the result is a JSON tree in Postgres. At build time, the page doesn't exist yet. At request time, I have a tree of maybe forty component instances, of which perhaps three actually need to run in the browser.

So I built runtime islands: server-render the whole tree to static HTML, mark only the interactive nodes, and let a single client script figure out at runtime what to mount and what to download.

The two options I rejected first

Render the whole page as one React root. This is the obvious move and it's what I did first. It works, and it means a landing page with a hero image, an FAQ accordion and a footer ships the entire component library to the browser so that React can re-render markup that will never change.

Mark every component as interactive. Same outcome with more ceremony.

Both fail for the same reason: the ratio. On a typical landing page in this system, the interactive components are the countdown timer and nothing else. On a checkout page, it's the address form, the card form and the coupon field. The static-to-interactive ratio is somewhere around 10:1, and paying for the 10 to get the 1 is the whole thing island architecture exists to avoid.

The server half

Puck (the builder library) takes a config of components and a data tree, and renders it. I wrap the config before rendering: any component whose name is in the interactive set gets its render replaced by one that emits a marker div plus its own props, serialized.

function islandRenderer(componentName: string, renderComponent: (props: any) => any) {
  return function RenderIsland(props: any) {
    return createElement(
      'div',
      { 'data-island': componentName, 'data-island-id': props.id || '' },
      createElement(renderComponent, props),
      serializedPropsScript(props),
    );
  };
}

function wrapInteractiveComponent(name: string, component: any) {
  if (!INTERACTIVE_COMPONENTS.has(name)) return component;
  return { ...component, render: islandRenderer(name, component.render) };
}
Enter fullscreen mode Exit fullscreen mode

Then the page is just renderToStaticMarkup. Note that it's renderToStaticMarkup, not renderToString: there are no hydration markers in the output, because nothing is going to hydrate in React's sense. More on that below.

The props script is a JSON script tag, which means the one escaping rule you cannot skip:

dangerouslySetInnerHTML: {
  __html: JSON.stringify(props).replace(/</g, '\\u003c'),
}
Enter fullscreen mode Exit fullscreen mode

Any user-authored string in those props can contain </script>. If you forget this line, your builder becomes an XSS delivery mechanism the first time someone types the wrong thing into a headline field.

The client half

The entire client-side runtime is one component, mounted once per page with client:only="react". It queries the DOM, collects the distinct island types actually present, and dynamically imports only those:

async function hydrateIslands() {
  const islands = Array.from(document.querySelectorAll<HTMLElement>('[data-island]'));
  if (islands.length === 0) return;

  const components = await loadComponents(collectIslandTypes(islands));

  for (const island of islands) {
    const type = island.getAttribute('data-island');
    const Component = type ? components[type] : null;
    if (!type || !Component) continue;
    const props = readIslandProps(island, type);
    if (props) mountIsland(island, Component, props, 'en');
  }
}
Enter fullscreen mode Exit fullscreen mode

loadComponents walks a plain map of dynamic imports:

const coreLoaders: Record<string, ComponentLoader> = {
  ShippingAddressForm: () => import('./blocks/ShippingAddressForm').then(m => m.ShippingAddressForm),
  PaymentEntryForm:    () => import('./blocks/PaymentEntryForm').then(m => m.PaymentEntryForm),
  CountdownTimer:      () => import('./blocks/CountdownTimer').then(m => m.CountdownTimer),
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The bundler splits every one of those into its own chunk. A page with a countdown timer downloads the countdown chunk. It does not download the Stripe form.

Three things that bit me

1. There are two lists, and they have to agree.

The server decides what to wrap (INTERACTIVE_COMPONENTS). The client decides what can mount (islandLoaders). They live in separate files because they're consumed by separate bundles, and nothing in the type system connects them.

Add a component to the server list only, and you ship a data-island div that never comes alive. It renders correctly, it looks fine, and the button does nothing. That is a genuinely nasty bug class because the failure is silent and visual inspection passes.

I fixed it the only way that actually holds: a test that renders a page through the server path and asserts the island wrapper plus the serialized props survive the round trip.

it('emits an island wrapper carrying default-merged scalar props', () => {
  const html = renderPuckToHtmlWithIslands(pageWith('CountdownTimer'));
  expect(html).toContain('data-island="CountdownTimer"');

  const props = extractIslandProps(html, 'CountdownTimer');
  expect(props.hours).toBe(2);
  expect(props.theme).toBe('block');
});
Enter fullscreen mode Exit fullscreen mode

2. It's createRoot, not hydrateRoot.

This is the part I'd argue about with someone, so let me state the trade honestly. React hydration wants the client's first render to match the server's markup exactly. My props don't come from a React render on the server; they come from a JSON blob that a user edited in a builder. Insisting on hydration means fighting mismatch warnings forever for a class of component that is about to re-render anyway (a countdown timer's server output is wrong by definition the moment it's serialized).

So the server markup is a paint placeholder, and createRoot replaces it on mount. What I get: no mismatch warnings, and one simple contract (props in a script tag) rather than two coupled ones. What I pay: the interactive subtree is rendered twice, and for anything time-dependent there's a visible correction on mount. For a countdown that's fine and arguably correct. If your interactive components are large and stable, real hydration is the better trade and you should take it.

3. Defaults must be merged before serialization.

The builder stores only the props a user explicitly changed. Everything else lives in the component's defaultProps. Server-side, Puck merges those for you during render, so the HTML looks right. But if you serialize the raw stored props, the client mounts the component with half its props undefined and the island renders differently from the markup it just replaced.

The fix is one call (applyPuckDefaults) before rendering, and the assertion props.hours === 2 in that test above exists specifically to catch its removal.

When not to do this

If your pages are known at build time, use client:load and stop reading. This entire mechanism exists to buy back a capability the compiler gives you for free when the page structure is static.

It pays off exactly when the content tree is authored at runtime by someone who is not you: a CMS, a page builder, a template marketplace. In that case the compiler can't help, and the choice is between shipping everything and building this.

The whole runtime is 246 lines across four files, and two of those files are just lists. That is the actual reason I'd recommend it over a framework: it's small enough to read in one sitting, which matters a lot for something sitting between your server output and your users' browsers.

Top comments (0)