@tuqulore-inc/eleventy-plugin-preact-island
This page covers the internal design and applied guidance for @tuqulore-inc/eleventy-plugin-preact-island, the plugin that drives Partial Hydration. For the API reference and installation, see the package README.
Why Partial Hydration
The boilerplate serves the initial paint as static HTML and hydrates only the parts of the page that need client-side JavaScript. Re-rendering the whole page with Preact is unnecessary for most content sites.
On content-heavy sites, most pages are static text and images. Loading a full SPA runtime on top of that hurts LCP and TTI just from the initial JS download and parse. Docs and article sites want to avoid that.
Some pieces still need client JavaScript — a search form, a nav toggle, an embedded counter. Partial Hydration pulls those pieces out and hydrates them individually.
Why is-land
Rolling Partial Hydration from scratch means writing initialization triggers (visible, idle, interaction) and bundle import ordering every time. @11ty/is-land provides these as a Web Component (<is-land>), and it composes cleanly with Eleventy.
This plugin adopts is-land as-is and layers Preact-specific hydration logic and a JSX wrapper (<Island>) on top. is-land itself is not hidden — when you need a parameterized trigger (for example on:media("(min-width: ...)")), you can drop down to the raw <is-land> element.
The .client.* convention
Client-side JavaScript lives under src/**/*.client.{js,jsx,ts,tsx}. Files with that sub-extension:
- Get bundled with esbuild and emitted at
dist/<input-relative path>.client.js. - Are excluded from Eleventy's template processing (they never render as pages).
- Have their bundled URL resolved automatically when passed to
<Island component={...}>on the SSR side.
This is a convention that declares "which files are client-side" without any additional configuration. There is no glob list to maintain in the config.
What <Island> does
<Island> is a wrapper that expresses both the SSR-side Preact render and the emitted <is-land> element in one JSX call.
import { Island } from "@tuqulore-inc/eleventy-preset/island";
import Counter from "./counter.client.jsx";
<Island component={Counter} on="interaction" initial={5} />;
The same props flow to the SSR render and the client-side hydration. What the browser sees after hydration matches what SSR rendered.
Props are serialized with devalue, so Date, Map, Set, and other types that JSON drops survive the trip as actual JavaScript values.
At the type level, the props passed to <Island> are checked against the props type of the component you pass.
In a project with type checking enabled (such as the default template), a misspelled prop or a missing required prop is a type error.
Riding Eleventy's pathPrefix
When you deploy to a subdirectory (for example GitHub Pages under /repo/), the <is-land import="..."> URLs need to match that prefix. The plugin reads Eleventy's own pathPrefix directly; there is no separate URL prefix option.
By riding an existing Eleventy concept instead of adding another knob, the developer sets the value in one place.
bundle: false for external bundlers
esbuild bundling is on by default (bundle: true). Pass bundle: false to switch it off — the plugin then only handles URL resolution, copying is-land.js, and writing and loading the unbundled setup module. You take responsibility for producing the bundle at the expected URL (<pathPrefix><input-relative path>.client.js) and for processing the setup module with the same bundler. See the @tuqulore-inc/eleventy-plugin-preact-island README for the exact requirements.
The point is: "zero-config out of the box, but replaceable when you need it." Changing bundling strategy does not force you to drop the plugin.
Deciding whether to use an Island at all
Islands exist so you can hydrate the pieces that need JavaScript. If a piece does not need JavaScript, do not wrap it in one. If any of the following works, prefer it over an Island.
<details>/<dialog>and other HTML elements that stand on their own (accordions, modals)- CSS state changes with
:targetor:has() - Plain links and page navigation
Ask "does this really need JavaScript?" first. On a docs site, where most reader interaction is search and navigation, the set of Island candidates ends up small on its own.
What to Island
Island candidates share these traits:
- Holds state: counters, forms, filter UIs, and so on.
- Reads external data: search, fetch, lazy loading.
- Renders differently based on user interaction: menu toggles, tab switching.
Anything else — a logo, static prose, a link list — does not need to be an Island. The SSR-rendered HTML is enough.
Fine or coarse
Making Islands smaller shrinks the hydrated JS per Island. The default is: hydrate the header menu, or the newsletter form in the footer, not the whole page.
But if neighboring UIs share state (a filter and its result list, for example), put them in the same Island. Cross-Island state sharing is not something Preact props or Signals can express on their own.
When in doubt, use "elements that touch the same state go into the same Island" as the tie-breaker.
Choosing a trigger
The on prop on <Island> picks when hydration happens.
| Value | Where it fits |
|---|---|
interaction | Wait until the user clicks or focuses. Fits most buttons and forms. This is the default. |
visible | Hydrate when the element scrolls into view. Fits interactive blocks lower on the page. |
idle | Hydrate once the browser is idle. Fits lightweight UI that has to work right after the initial paint. |
The tie-breaker is "how much time does the user actually give us before touching this Island?" — delay the ones with more slack, hydrate early only the ones that need it.
The type of on offers the three values in the table as editor completions while still accepting any other string.
Other is-land triggers such as load or save-data pass through as-is.
At runtime, however, a value that does not match /^[A-Za-z0-9_-]+$/ throws.
For parameterized triggers like on:media("(min-width: 40rem)"), skip <Island> and write the raw <is-land> element yourself. See the @tuqulore-inc/eleventy-plugin-preact-island README for details.
Match SSR and hydration
Do not use non-deterministic values like new Date() or Math.random() in the initial render of an Island. SSR and the client produce different values, and hydration mismatches. Use deterministic values for the first render and reflect dynamic values inside useEffect.
Islands that skip SSR
The approach in the previous section (a deterministic first render, with dynamic values applied in useEffect) leaves some components without a meaningful initial output in the SSR HTML.
These are components whose values always differ between SSR and the client, such as the current time; components whose initial output depends on browser-only state, such as localStorage; and components that need browser-only APIs to render.
Declare such a component with clientOnlyComponent() instead of clientComponent().
import { clientOnlyComponent } from "@tuqulore-inc/eleventy-preset/island";
import { useEffect, useState } from "preact/hooks";
function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
return <time>{now.toLocaleTimeString()}</time>;
}
export default clientOnlyComponent(Clock, import.meta.url);
<Island> does not render a client-only component during SSR; it outputs only what you pass as fallback inside <is-land>.
On the client, once the trigger fires, the fallback is removed and the component is rendered from scratch instead of hydrated.
Nothing is matched against the SSR HTML, so no mismatch occurs.
A client-only Island with on omitted renders once the page has loaded.
The default for regular Islands, interaction, waits for a click on the Island element, but a client-only Island holds only its fallback, which may be empty, so the click may never come.
An explicit on sets the trigger, so combining it with on="visible" gives an Island that skips SSR and renders for the first time when it enters the viewport.
<Island component={Clock} on="visible" fallback={<span>--:--:--</span>} />
Size the fallback like the rendered component. If the sizes differ, the layout shifts when one replaces the other.
Even when client-only, the component's module is still evaluated during SSR.
The SSR-side page imports it to pass it to <Island>.
A function component's body, on the other hand, runs only when it is called.
Keep browser-only APIs inside the component body or useEffect, not at the module's top level.
If a dependency touches browser-only APIs at its module's top level, SSR fails as soon as that package is imported.
In that case, write a thin client-only wrapper that imports the package dynamically inside useEffect.
useEffect runs only on the client, so SSR never evaluates the package.
import { clientOnlyComponent } from "@tuqulore-inc/eleventy-preset/island";
import { useEffect, useState } from "preact/hooks";
function Chart(props) {
const [Impl, setImpl] = useState(null);
useEffect(() => {
import("browser-only-chart").then((mod) => setImpl(() => mod.default));
}, []);
return Impl ? <Impl {...props} /> : null;
}
export default clientOnlyComponent(Chart, import.meta.url);