Server islands

Sometimes one region of an otherwise-static page needs fresh, per-request data — comments under a cached blog post, a stock badge on a product page. Server islands keep the page static and render just that region on a server when the page is viewed.

1. Write the island

An island is a fragment module under src/islands/ — a plain .js or .ts file (not .ht.js, because islands are fragments, not pages). Same idea as everywhere else in sitelo: a function that returns HTML.

export default async function comments({ props, request }) {
  const comments = await fetchComments(props.postId)
  return `<ul>${comments.map((c) => `<li>${c.text}</li>`).join('')}</ul>`
}

It receives { name, props, request } and must return an HTML string. Island modules are server-only — unreferenced code under src/ never ships to the browser.

2. Place it in a page

Import island() from sitelo/islands. The static build ships the fallback HTML; props are embedded in the placeholder, so keep them small and non-secret.

import { html, body, article, script } from 'javascript-to-html'
import { island } from 'sitelo/islands'

export default ({ params }) =>
  html(
    body(
      article('…static content…'),
      island('comments', { postId: params.slug }, '<p>Loading comments…</p>'),
      script({ type: 'module', src: '/islands.js' }),
    ),
  )

3. Add the client loader

A tiny script fetches each rendered fragment and swaps it in. It goes through the normal asset pipeline, so a plain src/islands.js entry is all you need:

import { mountIslands } from 'sitelo/islands/client'

mountIslands()

In sitelo (dev) and sitelo preview this already works — both serve islands at /_sitelo/islands/<name> from src/islands/. Preview loads native .js / .mjs modules (same as a Node host); TypeScript islands are supported in dev via Vite.

Production

Your static host keeps serving the pages. Mount a small handler wherever you run server code — Node, serverless, or an edge function — and it renders the same island modules. For a full walkthrough with a runnable Node host plus Netlify and Vercel stubs, see the Server islands example.

// e.g. a Node server, or a serverless/edge function
import { createIslandsHandler } from 'sitelo/islands/server'

const handleIslands = createIslandsHandler({
  islands: {
    comments: () => import('./src/islands/comments.js'),
  },
})

// Web Request → Response | null (null = not an island request)
export default { fetch: (request) => handleIslands(request) }

On plain Node http or express, use createIslandsNodeHandler(options) instead — same options, (req, res, next) signature. Auto-wire every .js / .mjs module under src/islands/ with createIslandsFromDirectory. If the loader fetches from a different origin or path, pass mountIslands({ endpoint: 'https://api.example.com/islands' }) and match it with the handler’s endpoint option.

Stability checklist

Loading strategies

By default every island fetches as soon as the page loads, so a page with eight islands makes eight simultaneous requests during first paint. Pass when to defer the ones that are not immediately visible.

// Load as soon as the page does — the default.
island('cart', { id }, '<p>…</p>')

// Wait for an idle callback.
island('recommendations', { id }, '<p>…</p>', { when: 'idle' })

// Wait until it scrolls into view.
island('comments', { postId }, '<p>Loading comments…</p>', {
  when: 'visible',
  rootMargin: '400px',   // start loading 400px early
})

rootMargin applies to 'visible' only and defaults to '200px'. Islands also time out rather than spinning forever:

mountIslands({
  timeout: 5000,        // per-island; 0 disables. Default 10000
  rootMargin: '300px',  // default for `when: 'visible'` islands
})

mountIslands() resolves once the immediate islands have settled — deferred ones load later on their own and are deliberately not awaited. A failed or timed-out island keeps its fallback HTML.

Props are untrusted input

Island props are client-supplied. They are embedded in the page, sent back on the request, and anyone can edit them first:

GET /_sitelo/islands/profile?props={"userId":"someone-else"}

Treat the props your island receives exactly like a query parameter — validate them, and never use them to look up data the viewer is not already entitled to see.

When props select privileged data, sign them. Set a secret and sitelo signs each placeholder at build time, rejecting anything else with a 403:

SITELO_ISLANDS_SECRET=$(openssl rand -hex 32) sitelo build

The same variable is read by sitelo, sitelo preview, and createIslandsHandler, so dev, preview and production agree. Give your production host the same secret. Prefer to set it in code?

import { configureIslands } from 'sitelo/islands'

configureIslands({ secret: process.env.MY_SECRET })

Signatures are HMAC-SHA256 over the island name and its props, so a signature issued for one island cannot be replayed against another. Signing proves the props came from your build — it does not hide them, so they must still be non-secret. Without a secret, props are accepted as-is and validating them is entirely your island module’s job.

Good to know