Server islands
On this page
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 { island } from 'sitelo/islands'
export default ({ params }) => `
<html>
<body>
<article>…static content…</article>
${island('comments', { postId: params.slug }, '<p>Loading comments…</p>')}
<script type="module" src="/islands.js"></script>
</body>
</html>
`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' }),
),
)import { island } from 'sitelo/islands'
export default function Post({ params }) {
return (
<html>
<body>
<article>…static content…</article>
{island('comments', { postId: params.slug }, '<p>Loading comments…</p>')}
<script type="module" src="/islands.js" />
</body>
</html>
)
}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
island()/mountIslands()/createIslandsHandler/createIslandsNodeHandler— public API, treated as stablesiteloandsitelo previewboth serve/_sitelo/islandsfromsrc/islands/createIslandsFromDirectory— same map for Node hosts and preview (native.js/.mjs/.cjs)- Host stubs in Server islands example: Node
server.js, Netlify function + rewrite, Vercel serverless + rewrite - Props stay small and non-secret (GET query string) — intentional; fetch secrets inside the island on the server
- Props are client-supplied — validate them, or sign them with
SITELO_ISLANDS_SECRET
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
})'load'(default) — immediately, alongside every other island'idle'— onrequestIdleCallback(falls back to a timeout)'visible'— when it scrolls into view, via IntersectionObserver
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 buildThe 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
- No island endpoint deployed? The fallback HTML simply stays — pages degrade gracefully.
- Requests are
GETwith props in the query string, so responses are cacheable — set the handler’scacheControloption if you want a CDN to hold fragments briefly. - While loading, the placeholder carries
data-sitelo-island-state="loading"(thenloadedorerror) — handy for CSS.