Server islands

sitelo builds static HTML. Server islands fill in the bits that must be fresh — a clock, comments, stock, anything that needs the request. This recipe builds a page with a time island, then runs a small Node server that serves dist/ and /_sitelo/islands.

A copy of this project lives in the sitelo repo under examples/islands/.

What you get

Project layout

my-site/
  sitelo.config.js
  server.js              # Node host: static dist + islands
  package.json
  src/
    index.ht.js          # page with an island placeholder
    js/
      islands.js         # client loader (bundled into dist/)
    islands/
      time.js            # server-only fragment module
    css/
      styles.css
export default {
  site: 'https://example.com',
}

1. Island module

Plain .js (not .ht.js). Receives { name, props, request } and returns an HTML string. This one uses the request time and user-agent so you can see it’s rendered per request.

export default function time({ props, request }) {
  const label = typeof props?.label === 'string' ? props.label : 'Server time'
  const now = new Date().toISOString()
  const ua = request?.headers?.get?.('user-agent') ?? 'unknown'

  return `
    <p><strong>${label}:</strong> <time datetime="${now}">${now}</time></p>
    <p class="muted">Rendered on request for <code>${escapeHtml(ua.slice(0, 48))}</code></p>
  `
}

function escapeHtml(value) {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
}

2. Place the island on a page

island() embeds props in the placeholder. The build ships the fallback; the loader replaces it when the endpoint responds.

3. Client loader

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

mountIslands()
body {
  font-family: system-ui, sans-serif;
  max-width: 36rem;
  margin: 2rem auto;
  padding: 0 1rem;
  line-height: 1.5;
}

[data-sitelo-island] {
  margin: 1.5rem 0;
  padding: 1rem 1.25rem;
  border: 1px solid #ccc;
}

[data-sitelo-island-state='loading'] {
  opacity: 0.7;
}

.muted {
  color: #666;
  font-size: 0.9rem;
}

4. Node host

After sitelo build, this process serves dist/ and renders islands with createIslandsNodeHandler from sitelo/islands/server. Island modules stay out of dist/ — the host imports them from src/.

import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createIslandsNodeHandler } from 'sitelo/islands/server'

const root = path.dirname(fileURLToPath(import.meta.url))
const dist = path.join(root, 'dist')
const port = Number(process.env.PORT) || 3000

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

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.png': 'image/png',
  '.ico': 'image/x-icon',
  '.xml': 'application/xml',
  '.json': 'application/json',
}

function sendFile(res, filePath) {
  const ext = path.extname(filePath)
  res.statusCode = 200
  res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
  fs.createReadStream(filePath).pipe(res)
}

function resolveStatic(urlPath) {
  const clean = decodeURIComponent(urlPath.split('?')[0])
  const relative = clean === '/' ? 'index.html' : clean.replace(/^\/+/, '')
  const candidate = path.normalize(path.join(dist, relative))

  if (!candidate.startsWith(dist + path.sep) && candidate !== dist) {
    return null
  }
  if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
    return candidate
  }

  const asIndex = path.join(candidate, 'index.html')
  if (fs.existsSync(asIndex) && fs.statSync(asIndex).isFile()) {
    return asIndex
  }

  return null
}

const server = http.createServer(async (req, res) => {
  await handleIslands(req, res, () => {
    const file = resolveStatic(req.url ?? '/')
    if (file) {
      sendFile(res, file)
      return
    }

    const notFound = path.join(dist, '404.html')
    res.statusCode = 404
    if (fs.existsSync(notFound)) {
      sendFile(res, notFound)
    } else {
      res.setHeader('Content-Type', 'text/plain; charset=utf-8')
      res.end('Not found')
    }
  })
})

server.listen(port, () => {
  console.log(`Listening on http://localhost:${port}`)
})

5. Build and run

npm install
sitelo build
node server.js

Open http://localhost:3000. You should see the fallback briefly, then the server time. Hit refresh — the timestamp changes. In sitelo (dev) you don’t need server.js: the CLI already serves /_sitelo/islands.

Deploy

For static hosts (Netlify, Vercel, Cloudflare Pages, AWS Amplify), copy the configs from the basic site example — they only assume npm run builddist/. Static hosts alone do not run islands; placeholders keep their fallback until you add a serverless/edge function with createIslandsHandler.

Node host

Run the same two steps on any Node host (VPS, Docker, Fly, Railway, …): build, then node server.js with PORT set by the platform.

For serverless or edge, use createIslandsHandler (web RequestResponse) instead of the Node adapter — see the Server islands docs. Point mountIslands({ endpoint }) at that function’s URL if it isn’t same-origin.

Notes

Static hosts alone

GitHub Pages, plain S3, and similar hosts have no server process. Without an islands endpoint the fallback HTML simply stays — pages still work, just without the live fragment.

Keep props small

Props travel in the HTML attribute and the request query string. Don’t put secrets or large payloads there — fetch those inside the island module on the server.

Server islands docs · Basic site / deploy · All examples