Skip to content

Search tags, filters, functions and guides.

Tags / island

Tags

island

Load part of a page after the rest, for live or slow data.

An island is a component that can load after the rest of the page. Use islands in a site you build to static files, for the few parts that need live data, such as stock levels, a basket or a visitor's name. On a server, use them to send the page before its slowest parts are ready.

{% island "components/stock" with { productId: product.id } %}
	<p>Checking stock…</p>
{% endisland %}

island takes a template and props, like component. The content inside the tag is the fallback, which the page shows until the island loads.

How islands render

The template doesn't decide when an island renders. The code that renders the page does, with the islands render option:

  • By default, an island renders in place, exactly like a component, and its fallback isn't used. This suits development, tests, pages your server renders for each request, and static sites with no server.
  • With islands: deferIslands(…), each island becomes a <kiln-island> element holding its fallback. In the browser, it fetches the island's HTML from your server and swaps it in. See Deferring islands.
  • With islands: streamIslands() and renderStream, the page is sent with each island's fallback, and each island follows in the same response when it is ready. See Streaming islands.

So the same templates work for a static build, a server and your tests.

Props

  • Props must be JSON values: null, booleans, numbers, strings, lists and hashes. Pass dates as strings or timestamps. The rule is the same when islands render in place, so a template that works in development also works deferred.
  • The island only sees its props and globals, like a component.
  • An island has no slots. The content inside the tag is only the fallback, and it renders in the caller's scope.
  • A deferred or streamed island renders on its own, so it can't add to the page's outlets.

Make the fallback the same size as the island, so the page doesn't jump when the island loads.

Deferring islands

Pass the same deferIslands object to every page you render in a build. It records each island it meets, and its manifest lists them all. Save the manifest with your server code.

import { writeFile } from "node:fs/promises";
import { deferIslands } from "@lucidkiln/core";

const islands = deferIslands({ url: "/_islands/" });

for (const page of pages) {
	await writeFile(page.file, await kiln.render(page.template, page.data, { islands }));
}

await writeFile("build/islands.json", JSON.stringify(islands.manifest));

The island from the top of this page becomes:

<kiln-island src="/_islands/4f0b6c1d9e2a7385">
	<p>Checking stock…</p>
</kiln-island>

The manifest holds each island's template and props, by id:

{
	"4f0b6c1d9e2a7385": {
		"template": "components/stock.kiln",
		"props": { "productId": 42 }
	}
}
  • Only the id reaches the browser. The props stay in the manifest, on your server.
  • An id comes from the template and props, so an island has the same id on every page and in every build. After you deploy, a page that's still cached somewhere asks for an id that either means the same island or doesn't exist.
  • Kiln adds the id to the end of url, so "/_islands?id=" works too.
OptionDefaultDescription
urlRequiredThe text before each island's id in its src
scripttrueWhether Kiln adds the script that runs <kiln-island>. See Content Security Policy

Serving islands

Your server looks up the id in the manifest and renders the island with render, as it would a page. Kiln doesn't add a route, so this works with any server. Here it is in a Cloudflare Worker:

import type { IslandManifest } from "@lucidkiln/core";
import islands from "./build/islands.json";

const manifest: IslandManifest = islands;

export default {
	async fetch(request: Request) {
		const { pathname } = new URL(request.url);
		if (pathname.startsWith("/_islands/")) {
			const id = pathname.slice("/_islands/".length);
			if (!Object.hasOwn(manifest, id)) {
				return new Response("Not found", { status: 404 });
			}
			const { template, props } = manifest[id];
			const html = await kiln.render(template, props, {
				state: { cookie: request.headers.get("cookie") },
			});
			return new Response(html, {
				headers: {
					"content-type": "text/html; charset=utf-8",
					"cache-control": "private, no-store",
					"x-robots-tag": "noindex",
				},
			});
		}
		return fetch(request);
	},
};
  • Each island loads with its own request, which carries the visitor's cookies. Pass what the island needs from the request as state.
  • Check who is signed in here, as you would for any page. An id says which island to render, not who may see it.
  • Choose the caching for each island. Use public with a short max-age for data everyone sees, and private for one visitor's data.
  • x-robots-tag: noindex stops search engines listing island responses as pages.
  • Islands inside an island render in place, since the island is already live.

Streaming islands

On a server, pass streamIslands() to renderStream. The page doesn't wait for its islands: each one starts rendering when the page reaches it, and its fallback is sent in its place. When the page is done, each island's HTML follows as soon as it is ready, in the same response.

import { streamIslands } from "@lucidkiln/core";

const stream = await kiln.renderStream("pages/product", { product }, {
	islands: streamIslands(),
	signal: request.signal,
});

The island from the top of this page is sent first as:

<kiln-island stream="1">
	<p>Checking stock…</p>
</kiln-island>

And once it is ready, after the rest of the page:

<template><p>Stock for 42</p></template><kiln-island fill="1"></kiln-island>
  • Islands arrive in the order they finish, not the order they appear in the page.
  • Kiln adds them after everything the page writes, even </html>. Browsers put them in the body, where the script moves each into its place. Without JavaScript, the fallbacks stay.
  • An island renders with the same state, locale and time zone as the page.
  • If an island fails, the stream ends with its error, as for any error once the page has been sent. Islands that haven't arrived keep their fallbacks.
  • The islands count towards the page's limits. maxOutputLength and maxSteps count them as part of the page, and each island's nesting starts at the depth of its tag.
  • Islands inside cache, set, apply or a macro render in place, since their output may be reused or never sent.
  • render and renderBlock can't stream, so with streamIslands() they render islands in place.
OptionDefaultDescription
scripttrueWhether Kiln adds the script that runs <kiln-island>. See Content Security Policy

The kiln-island element

Kiln adds a small script before the first deferred or streamed island in each page. It defines <kiln-island>:

  • As soon as a deferred island's element is on the page, it fetches its src. A streamed island waits for its HTML to arrive.
  • If the response succeeds and is HTML, the element replaces itself with it. Scripts in the HTML run.
  • If the request fails, or JavaScript is off, the fallback stays.
  • The element has display: contents, so it doesn't change the page's layout.

Content Security Policy

If your Content Security Policy blocks inline scripts, pass script: false and serve the script as a file, or add it with your nonce. islandScript holds its source.

import { deferIslands, islandScript } from "@lucidkiln/core";

const islands = deferIslands({ url: "/_islands/", script: false });
await writeFile("build/kiln-island.js", islandScript);

Then load it in your layout, with <script src="/kiln-island.js" defer></script>. Islands already on the page start loading when it runs.

Islands in cached sections

A cache section can hold islands. A saved copy remembers its deferred islands, so reusing it in a later render still adds them to the manifest. Copies rendered in place and copies with deferred islands are saved separately. Streamed islands render in place inside a cached section.