Exploring Islands Architecture, DIY implementation

A pattern that balances performance, interactivity, and SEO... explore how it works under the hood.

Exploring Islands Architecture, DIY implementation

You may have heard the term "Islands Architecture" being use online, usually it's most commonly associated with frameworks like Astro or Fresh.

Because of this, to some extent it could look like these frameworks are doing magic behind the scenes. That's why the main goal of this article is to demonstrate how to implement this pattern, and hopefully give you a better idea of how these types of optimizations work, as they can be achieved on most website with just a little bit of JavaScript.

The islands architecture

First of all, what is the islands architecture?

The overall main idea of this pattern is to inject the absolute minimum amount of Javascript possible, while having a good balance between interactivity, performance, and SEO, the latter is usually achieved via either Server Side Rendering (SSR) or Static Site Generation (SSG) also known as pre-rendering.

Since this is a component-based architecture, we can separate the static (non-interactive) components from the ones that need some client-side JavaScript to work.

At which point, we only hydrate (load JS for) specific components that provide some kind of user interactivity, while the other static regions of the page stay as pure non-interactive HTML that do not require hydration.

Let's imagine a website that looks like the following:

Islands of interactivity.Non-interactive UI components.

I'm sure you noticed, most of the user interface is pretty much made of non-interactive elements that can be rendered during a request to the server or build time. In this example, only a few components need JavaScript to execute some kind of client site code. This is often the case when it comes to content driven websites like marketing sites, blogs, and e-commerce.

To some degree, this architecture promotes using Web standards by taking advantage of native browser features, rather than reinventing the wheel for things the browser and servers already know how to do. That's the case for most single-page-applications as they often make use of a client site routing, caching, form handling/validation, and similar libraries that quickly add up slowing down and complicating things over time.

Fortunately most websites dont really need to deal with that type of complexity...

Now, let's explore how to build a custom islands implementation.

Technologies

Let's break down the technologies we'll actually be using for this exercise:

Vite

The Build Tool for the Web

Simply put, Vite is one of the best JavaScript build tools available. It's really fast, easy to use, and UI agnostic, meaning it can bundle various frontend frameworks, Preact being one of them.

Preact

Fast 3kB alternative to React with the same modern API

When it comes to islands architecture, Preact is my UI framework of choice. Its main selling points are:

  1. Familiar React-like API with support for JSX, Hooks, function components, Context, and even Suspense.
  2. Overall better performance and a hilariously smaller in bundle size.

Here is a quick comparison:

That is 64.1kB - a huge 165% difference!! so let's remember with islands we are looking to inject the minimum amount of Javascript possible, using Preact sounds like the more reasonable approach.

Web components

A web standard for creating reusable UI elements

Well, kind of 😃... they turned out to be a very niche standard, but to be fair, I think they got some really interesting use cases like in this specific situation.

We’ll create a custom element that will work as a wrapper for specific Preact components and will enable them to be hydrated independently. As a plus, we could pass attributes to specify when hydration should happen, for example when visible or when a given media query matches.

The implementation

TLDR; Here's a demo put together with the steps blow: github.com/fveracoechea/preact-islands-demo

Enough talk, let's start writing some code...

Scaffolding

First, let's generate a Vite + Preact project by running pnpm craate preact or npm init preact, as well make sure to select the following options so that both router and pre-rendering are enabled:

  • Project language: TypeScript
  • Use router?: Yes
  • Prerender app (SSG)? Yes

After running npm run dev or pnpm dev you should see the following:

Vite + Preact

Utility functions

At this point, we want to allow for components to be rendered on the server as placeholders and then hydrated into interactive elements on the client. Two key functions make this happen:

// src/lib/preact-islands.tsx
import type { FunctionalComponent, JSX } from "preact";
import hydrate from "preact-iso/hydrate";

export type IslandsConfig = Record<
  string,
  () => Promise<{ default: FunctionalComponent }>
>;

export function registerIslands<C extends IslandsConfig>(config: C) {
  customElements.define(
    "preact-island",
    class extends HTMLElement {
      async connectedCallback() {
        const src = this.getAttribute("src");

        if (!Object.prototype.hasOwnProperty.call(config, src))
          throw new Error(`${src} is not a registered island`);

        const load = config[src];
        const Component = await load();
        hydrate(<Component.default />, this);
      }
    },
  );
}

export function withIsland<S, Props = {}>(
  Component: FunctionalComponent<Props>,
  displayName: string,
): FunctionalComponent<Props> {
  return (props: Props) => {
    if (typeof document !== "undefined") return <Component {...props} />;
    return (
      <preact-island src={displayName}>
        <Component {...props} />
      </preact-island>
    );
  };
}

Here is a quick breakdown:

registerIslands()

It's where most of the magic happens, basically this function registers a preact-island custom element that tells the browser to lazy-load the corresponding component's code (via connectedCallback), then hydrates it, making it interactive.

withIsland()

A very simple higher-order component that marks a given Preact component as an "island."

When running in the browser, it does during hydration, so it renders the Component directly using any provided props. But when running on the server, it renders the Component wrapped by a custom <preact-island> HTML tag with a src attribute pointing to the name of the island.

It is important to notice that displayName should match one of the keys in the config object passed to registerIslands().

Writing a simple island

Let's start by building a simple button as our first island component. This simple button will trigger a window.alert when clicked:

// src/islands/Button.tsx
import { withIsland } from "../lib/preact-islands";

function Button() {
  return (
    <button
      style={{ width: 300, fontSize: 16 }}
      onClick={() => alert("Interactivity Yeah!!")}
    >
      <span>Button Island - Click me!</span>
    </button>
  );
}

export default withIsland(Button, "Button");

Notice that the component is wrapped with withIsland(), which requires the component itself and a displayName as arguments. The displayName is used to identify the component during hydration.

Loading islads

Finally! The last piece of the puzzle! it is basically configuring Vite to ensure only the necessary "islands" are sent to the browser, rather than the entire app. Luckily this can be easily done using the dynamic import() function, follow the next steps:

Move app component

First, copy/paste the main App component into a separate file:

// src/App.tsx
import { LocationProvider, Route, Router } from "preact-iso/router";

import { Header } from "./components/Header.jsx";
import { Home } from "./pages/Home/index.jsx";
import { NotFound } from "./pages/_404.jsx";

export function App() {
  return (
    <LocationProvider>
      <Header />
      <main>
        <Router>
          <Route path="/" component={Home} />
          <Route default component={NotFound} />
        </Router>
      </main>
    </LocationProvider>
  );
}

Add code splitting

Now, modify the src/index.tsx entrypoint to handle different behaviors for development and production:

// src/index.tsx
import hydrate from "preact-iso/hydrate";
import render from "preact-iso/prerender";

import { registerIslands } from "./lib/preact-islands";
import "./style.css";

// the dev server (dev command) runs in development mode
const isDEV = import.meta.env.MODE === "development";
const isBrowser = typeof window !== "undefined";

if (isBrowser && isDEV) {
  // Hydrate the whole app while in development
  import("./App").then(({ App }) => {
    hydrate(<App />, document.getElementById("app"));
  });
}

if (isBrowser && !isDEV) {
  // Only hydrate Islands in production
  registerIslands({
    Button: () => import("./islands/Button"),
  });
}

export async function prerender(data) {
  const { App } = await import("./App");
  return await render(<App {...data} />);
}

Please make sure you notice the following:

  • In development, the entire app is hydrated to simplify debugging.
  • In production, only the defined islands are hydrated using the registerIslands() function.
  • Button key in the registerIslands() configuration matches the displayName arg provided to withIsland(). This link is really important for identifying and loading the correct island dynamically.

Render it on the page!

At this point, we should be able to render our button island anywhere on the page, so let's do that and run:

npm run build && npm run preview

This is what you would get on localhost:4173:

Button Island

Not so fast, there are a couple more thigs I'd like to mention...

I'd suggest adding the following snippet to your global CSS styles:

preact-island {
  display: contents;
}

It should help preventing styling issues if any, by making it so that the custom element is not perceived as a box by the browser, and instead is replaced by its child.

Also if you are using TypeScript and I hope you are... add the following snippet somewhere in the code, it would make TypeScript recognize preact-island as a usable html tag:

declare module "preact/jsx-runtime" {
  namespace JSX {
    interface IntrinsicElements {
      "preact-island": JSX.HTMLAttributes<HTMLElement>;
    }
  }
}

Final thoughts

All in all, this is just one of many ways to implement this pattern. The main goal is to use UI components to generate a static HTML skeleton of the page and then add interactivity as needed. When working with different technologies, I believe it's just a matter of adapting to the build tools and server setup being used. This approach can most likely be applied to custom server-side rendering or most static site generators. For the latter, I recommend checking out Lume and Eleventy.

Personally, I find it to be a solid solution, especially for performance-sensitive websites that should be lightweight and focused on displaying content to users as quickly as possible.

As a plus, it also enables Progressive Enhancement, which is the idea that your page work with HTML first, allowing everyone to access the basic content and functionality as a baseline, and then enhancing the user experience for those with advanced browser features or faster internet access.

All that being said, should You roll out your custom islands to production?

Probably not. I'd say it's safer to go for a framework like Astro or Fresh, as they offer documentation and more features that can be useful over time. However, I still think it may be ok if we're talking about a more personal site.

Thanks for reading this article! I really hope it sparks some new ideas and encourages you to try your own DIY implementation of this pattern.

Demo repo: github.com/fveracoechea/preact-islands-demo

Resources

Here are a couple resources, in case you'd like to dig more in to this subject: