On this page
  1. Declare the directive as an island
  2. Build the live component
  3. Register it on rendering.islands
  4. Mount the runtime in your root layout
  5. Choose eager or deferred mounting
  6. Keep the fallback the real first paint
  7. Verify the live island on the deployed page
  8. Related reference

Add an island

cairn’s render pipeline runs on the server, so a directive component renders to static HTML: a callout or an alert never needs client code. Some content wants to be interactive, though, and cairn’s seam for that is the hydrate flag on defineComponent paired with a matching entry on your adapter’s rendering.islands. This guide wires one up end to end, using the showcase’s own banner directive throughout. The banner is a time-boxed announcement that renders until its expires date passes, then checks that date again independently at hydration: cairn.config.ts declares it and Banner.svelte is the live component that mounts over it. This guide assumes you already have a directive component rendering through your registry. Configure rendering builds one from nothing.

Declare the directive as an island

Start from an ordinary component definition and add hydrate: true. The attributes stay exactly as declared: they’re what the live component receives as props, so keep them scalar and give the build a fallback that states the same content the live component starts with.

import { defineComponent, fields } from '@glw907/cairn-cms';
import { strAttr } from '@glw907/cairn-cms/render';
import { h } from 'hastscript';
import { isBannerExpired } from './banner-expiry.js';

const banner = defineComponent({
  name: 'banner',
  label: 'Announcement banner',
  description: 'A time-boxed announcement that removes itself once its expiry date passes.',
  hydrate: true,
  insertTemplate: ':::banner{message="Announcement text" expires="2026-12-31"}\n:::',
  attributes: {
    message: fields.text({ label: 'Announcement', required: true }),
    expires: fields.date({
      label: 'Expires',
      required: true,
      help: 'The banner shows through the end of this date, then renders nothing.',
    }),
  },
  build: (ctx) => {
    const message = strAttr(ctx, 'message') ?? '';
    const expires = strAttr(ctx, 'expires');
    if (isBannerExpired(expires)) return h('div', { hidden: true, className: ['banner-expired'] }, []);
    return h('div', { className: ['banner'], role: 'status' }, [
      h('p', { className: ['banner-message'] }, [message]),
    ]);
  },
});

isBannerExpired is a small helper the showcase shares between build() and the live component (examples/showcase/src/theme/islands/banner-expiry.ts), so the server and the client agree on what “expired” means without sharing any state across that boundary: each evaluates the same rule fresh, at its own render or hydration moment.

With hydrate set, the render pipeline wraps this build() output in an island boundary rather than emitting it as-is: a <div> carrying the directive name and the attributes, serialized to JSON, as inert data-* attributes. message and expires are both string-shaped fields, so both stay the literal string the author wrote. A number or boolean-typed attribute would round-trip as a real JSON value instead. The islands reference documents the boundary’s exact shape.

Build the live component

The live component is a plain Svelte component. Its props are the directive’s attributes, so name them the same way and give each a default, since an inserted directive might not set every attribute yet:

<!-- src/lib/islands/Banner.svelte -->
<script lang="ts">
  import { isBannerExpired } from './banner-expiry.js';

  interface Props {
    message?: string;
    expires?: string;
  }

  let { message = '', expires }: Props = $props();
  const expired = $derived(isBannerExpired(expires));
</script>

{#if !expired}
  <div class="banner" role="status">
    <p class="banner-message">{message}</p>
  </div>
{/if}

expired is a $derived value rather than an $effect, so the check runs once, lazily, on first read, with no interval timer. A banner doesn’t need to vanish partway through a visit, only stay correct across a fresh mount: a statically built or long-cached page can outlive its expires date, so the client re-checks it independently rather than trusting whatever the server decided at build time.

The props arriving here are author-controlled and untrusted. They’re an editor’s own directive attributes, escaped by the pipeline and parsed on the client, but never validated against your component’s expectations beyond their declared type. Bind every one of them to text, the way message lands as a plain interpolated string in the preceding component. Never route a prop into {@html}, an href or src that could carry a javascript: scheme, or an inline style. The engine only guarantees the prop arrives as escaped data. Where it goes next is up to your component, so keep it out of those sinks. The props section of the islands reference covers the full trust boundary.

Register it on rendering.islands

Key the live component by the directive’s name, the type-safe join between the two declarations:

import type { IslandRegistry } from '@glw907/cairn-cms/islands';
import Banner from '$lib/islands/Banner.svelte';

const islands: IslandRegistry = { banner: Banner };

defineAdapter checks this join in both directions at module load, the same moment defineConcept checks your concepts’ permalinks: a hydrate component with no islands entry, or an islands entry with no hydrate component, fails to load. You catch the mistake in your editor or dev server instead of shipping a directive that never becomes interactive.

Mount the runtime in your root layout

One call, in your root layout, mounts every island on the page after every navigation:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { afterNavigate } from '$app/navigation';
  import { cairn } from '$lib/cairn.config';

  let { children } = $props();

  afterNavigate(async () => {
    const islands = cairn.rendering.islands;
    if (!islands || Object.keys(islands).length === 0) return;
    const { hydrateIslands } = await import('@glw907/cairn-cms/islands');
    hydrateIslands(islands);
  });
</script>

{@render children()}

The empty-registry check keeps the dynamic import out of a site’s bundle entirely until it registers a first island, so a static site never ships the runtime. hydrateIslands tears down the previous mount before creating the new one. Call it from afterNavigate, not a one-time onMount, so each client-side navigation gets exactly one instance per boundary.

Choose eager or deferred mounting

hydrate: true mounts eagerly, on first load and after every navigation. hydrate: 'visible' defers the same mount to the moment the island’s boundary first scrolls into view, then stops watching it. Reach for 'visible' on an island that sits low on a long page, so a reader who never scrolls that far never pays for the mount at all. A banner near the top of a page, the one built here, is exactly the eager case: it’s likely to be visible already, so there’s nothing to gain by deferring it.

Keep the fallback the real first paint

For a 'visible' island, or any reader without JavaScript, the build() output is the only content that ever renders, so treat it as the page’s first paint, not a placeholder. Make it state the same thing the live component starts with, at close to the same size. A fallback that’s shorter or narrower than the mounted component shifts the layout the moment the runtime replaces it. Style it with classes rather than an inline style attribute. The render pipeline’s sink guard strips inline styles and event handlers from directive output, so a fallback that depends on one silently loses it.

Verify the live island on the deployed page

The admin’s edit-page preview renders only the build() fallback: its frame runs with sandbox="", so no script executes and the island runtime never mounts there. This is expected. Check a live island the way any reader actually encounters it, on the deployed page (or a local vite dev / wrangler dev run of it), never in the preview.

The islands reference documents hydrateIslands, the IslandRegistry type, and the full island boundary contract. hydrate on the components reference and rendering.islands on the core reference cover the two declarations this guide joined. Configure rendering and Define an adapter and schema cover the registry and the adapter this guide assumed were already in place.

Edit this page on GitHub(opens in a new tab)