SolidStart Islands & Partial Hydration

Solid already pays one of the lowest hydration bills in the ecosystem, because it never builds a virtual DOM to reconcile. SolidStart’s islands mode takes that advantage further: instead of hydrating a whole route, it activates only the interactive regions you designate and leaves the rest of the page as inert server-rendered markup. For performance engineers, the combination is compelling — fine-grained reactivity means hydration cost scales with the number of reactive bindings rather than the size of the DOM, and islands mode means most of the DOM has no bindings at all. This page explains how Solid’s reactivity model and SolidStart’s islands mode interlock to deliver true partial hydration, and sits within the broader Framework-Specific Islands & Streaming SSR strategies for shipping less JavaScript.


Virtual-DOM Hydration vs Solid Fine-Grained Hydration On the left, virtual-DOM hydration re-creates the component tree in memory, diffs it against the server DOM, then attaches listeners — cost scales with node count. On the right, Solid walks the existing DOM once and binds reactive signals directly to the specific text nodes and attributes they control, with no diff — cost scales with binding count. VIRTUAL-DOM HYDRATION SOLID FINE-GRAINED HYDRATION 1 · Re-create component tree in memory every node re-instantiated 2 · Diff vdom against server DOM reconciliation — O(nodes) 3 · Attach listeners across whole tree Cost scales with DOM size 1 · Walk existing server DOM once no re-instantiation 2 · Bind signals to exact nodes no diff — O(bindings) 3 · Done — reactive graph is live Cost scales with binding count <p>Static heading</p> count() → this text node <p>More static markup</p>

Concept Definition & Scope

Two distinct ideas combine on this page, and it helps to separate them before wiring them together.

The first is Solid’s reactivity model. Solid compiles JSX to imperative DOM-creation code at build time. There is no virtual DOM and no component re-render: a createSignal produces a getter/setter pair, and reading that getter inside JSX creates a fine-grained subscription so that when the signal changes, Solid updates exactly the text node or attribute that depends on it — nothing else re-runs. On the server, the same components render to an HTML string. On the client, hydrate walks that server-rendered DOM and re-establishes those fine-grained subscriptions against the existing nodes. Because there is no tree to reconcile, hydration cost is a function of how many reactive bindings a component has, not how many DOM nodes it produced.

The second is SolidStart islands mode. By default a SolidStart route is server-rendered and then hydrated in full. Islands mode inverts that: page markup is static unless a component is designated an island, and only islands ship JavaScript and hydrate. This is the same hydration boundary discipline that Astro islands and client directives enforce with directives, but Solid’s fine-grained runtime means each island’s hydration is already unusually cheap.

The multiplicative effect is the point: islands mode shrinks how much of the page hydrates, and fine-grained reactivity shrinks how expensive each hydration is. The scope of this guide is exactly that intersection — enabling islands, choosing island entry points, using clientOnly for browser-only widgets, and reasoning about the no-vdom hydration cost. The precise configuration recipe lives in configuring SolidStart island hydration directives.

Technical Mechanics

Fine-grained reactivity and what hydration actually does

A Solid island is an ordinary Solid component. What makes hydration cheap is that its reactive bindings are the only live wires after the DOM is already present.

// ~/components/LikeButton.tsx
// An island component. createSignal returns a [getter, setter] pair.
import { createSignal } from "solid-js";

export default function LikeButton(props: { initial: number }) {
  // On hydration, Solid does NOT re-run this to build a tree — it reuses the
  // server-rendered DOM and binds `count` to the exact text node below.
  const [count, setCount] = createSignal(props.initial);

  return (
    <button
      // The click handler is the only listener attached for this island.
      onClick={() => setCount((c) => c + 1)}
      aria-label="Like"
    >
      {/* Only THIS text node is a reactive binding. Updating count() rewrites
          just this node — no component re-render, no diff. */}{count()}
    </button>
  );
}

Derived state uses createMemo, and side effects use createEffect — both create their own fine-grained subscriptions rather than triggering component-level work:

// ~/components/Cart.tsx
import { createSignal, createMemo, createEffect } from "solid-js";

export default function Cart(props: { items: { price: number }[] }) {
  const [items, setItems] = createSignal(props.items);

  // createMemo caches a derived value; it re-computes ONLY when items() changes,
  // and only its own dependents update — hydration wires this into the graph.
  const total = createMemo(() =>
    items().reduce((sum, i) => sum + i.price, 0)
  );

  // createEffect runs after hydration and re-runs only when total() changes.
  // Use it for genuine side effects, not for rendering.
  createEffect(() => {
    document.title = `Cart · £${total().toFixed(2)}`;
  });

  return <output>Total: £{total().toFixed(2)}</output>;
}

clientOnly for components that must not server-render

Some components cannot render on the server: they read window, touch localStorage during render, or wrap a library that assumes a browser. Server-rendering them either crashes SSR or produces markup that differs from the client, causing a hydration mismatch. clientOnly skips SSR for that subtree and mounts it purely in the browser.

// ~/routes/dashboard.tsx
import { clientOnly } from "@solidjs/start";

// The map widget reads the DOM and window at construction time, so it must not
// run on the server. clientOnly defers the import and mount entirely to the
// client, rendering a fallback in its place during SSR.
const HeatMap = clientOnly(() => import("~/components/HeatMap"));

export default function Dashboard() {
  return (
    <main>
      <h1>Usage</h1>
      {/* Static content around the island still server-renders normally. */}
      <HeatMap fallback={<div style="min-height:320px">Loading map…</div>} />
    </main>
  );
}

The trade-off is explicit: a clientOnly component contributes nothing to the initial HTML (so it cannot be your LCP element), but it also carries zero hydration mismatch risk because there is no server output to diverge from. For the exact configuration and island-entry conventions, see configuring SolidStart island hydration directives.

Passing state across the boundary

Islands receive their initial state as props, serialised at the server-client handoff. Keep those props plain and JSON-serialisable; passing functions or class instances across the boundary is the most common source of hydration failures, exactly as covered in cross-boundary prop passing. Solid serialises island props automatically, but only for values it can serialise.

Fine-Grained Attachment Versus Tree Reconstruction Two columns. The virtual-DOM column rebuilds a component tree in memory, diffs it against the server markup, then attaches listeners — work proportional to node count. The fine-grained column walks straight to the handful of nodes a signal touches, binds an effect to each, and leaves the rest of the markup untouched — work proportional to the number of reactive bindings, not to the size of the page. Work is proportional to bindings, not to nodes virtual DOM hydration 1 · re-run every component function 2 · build a virtual tree in memory 3 · diff it against the server markup 4 · attach listeners to matched nodes 1 800 nodes → 1 800 units of work, whether or not any of them will ever change fine-grained attachment 1 · read the island's serialised props 2 · walk to the marked reactive nodes 3 · bind one effect per binding no diff, no tree, no re-render 1 800 nodes but 9 bindings → 9 units of work, and the static markup is never touched again

SolidStart Islands vs Other Hydration Models

Dimension SolidStart islands (fine-grained) Full-page Solid hydration Virtual-DOM islands (React/Preact)
Hydration unit Designated island components only Entire route tree Directive-marked components
Per-island cost O(reactive bindings) — no diff O(reactive bindings), whole page O(nodes) — re-render + reconcile
Static markup JS cost 0 KB Runtime + all component code 0 KB for unmarked components
Re-render model None — signals update exact nodes None — signals update exact nodes Component re-render on state change
Browser-only widgets clientOnly skips SSR cleanly clientOnly or isServer guards Dynamic import + ssr:false
Mismatch risk surface Small — few islands, plain props Whole tree must match Whole marked subtree must match
Best fit Content-heavy pages, few interactive zones Highly interactive app-like routes Existing React estates adopting islands

The column that most often decides adoption is per-island cost. Because Solid never reconciles, moving from a virtual-DOM islands framework to SolidStart typically reduces the hydration overhead of each interactive zone even before islands mode removes zones entirely.

Step-by-Step Integration

  1. Enable islands mode in app.config.ts. Turn on the experimental islands flag so SolidStart treats markup as static by default and only hydrates designated islands. The full configuration is detailed in the island hydration directives guide.
// app.config.ts
import { defineConfig } from "@solidjs/start/config";

export default defineConfig({
  // ssr: true keeps server rendering on; islands mode then narrows hydration
  // to designated island components instead of the whole route.
  ssr: true,
  experimental: { islands: true },
});
  1. Keep route components static. Author your routes as plain server-rendered Solid components — headings, layout, copy. Do not introduce signals or effects at the route level; push them down.

  2. Extract interactive UI into small island components. Each island should be a tight leaf: a like button, a cart summary, a filter control. Small islands keep each client chunk small and each hydration cheap.

  3. Use clientOnly for browser-only widgets. Anything that reads window or wraps a DOM-only library goes through clientOnly with a sized fallback so SSR stays clean and layout is reserved.

  4. Serialise island props as plain data. Pass numbers, strings, and plain objects/arrays across the boundary. For anything richer, follow the serialisation patterns in cross-boundary prop passing.

  5. Measure the split. Build for production and confirm that a mostly static route ships only the island chunks — no route-wide bundle. This is your guard against islands mode silently regressing to full hydration.

Where Islands End and the Router Begins

SolidStart sits in an unusual position: it can render a route as a fully client-navigated application, as a set of islands inside a server-rendered document, or as a mixture where some routes hydrate fully and others ship almost nothing. Choosing per route rather than per project is the point, and the choice hinges on what happens at navigation.

A fully client-navigated route keeps a router and a component graph resident in memory, so moving between two such routes costs a data fetch and a render — no document request, no re-parse. That is worth paying for in an application shell where visitors move constantly between views. An island route, by contrast, is a document: navigating to it is a fresh request, and its islands activate from scratch. That is worth paying for on entry pages, where the visitor arrives cold and most of them will read rather than navigate.

The mistake to avoid is mixing the two models within one navigation surface. If half your primary navigation performs a client-side transition and the other half triggers a document load, the interface feels inconsistent in a way visitors notice but cannot name — one link responds instantly, its neighbour flashes white. Draw the line at a boundary that matches how people actually move through the product: marketing and content routes as islands, the authenticated application as a navigated shell, and one deliberate document load at the seam between them.

Inside an island route, the server still owns data loading. Route-level loaders run on the server, their results are serialised into the document, and islands read those values as props rather than re-fetching on mount. Skipping that step is the most common cause of a route that looks fast in a synthetic test and feels slow in the hand: the markup arrives quickly, then every island issues its own request and the page fills in piecemeal several hundred milliseconds later.

Measurement & Validation

Confirm which components hydrated. A short effect that logs on mount tells you, per island, that hydration ran there and only there:

// Drop into an island temporarily to confirm it hydrated (and that a
// supposedly-static component did NOT log anything).
import { onMount } from "solid-js";
onMount(() => performance.mark(`island:hydrated:${props.id}`));

Then read the marks back with a PerformanceObserver, exactly as in the core measurement workflow. A static component that never logs is proof the island boundary held.

Confirm the payload in DevTools. In the Network panel, filter for scripts and verify a content-heavy route requests only your island chunks. In the Performance panel under a 4G / CPU 4× profile, the hydration tasks should be short and sparse — the absence of a wide reconciliation flame is the visible signature of no-vdom hydration.

Assert island props are serialisable. Add a test that renders a route to a string and parses the serialised island props out of the HTML. If serialisation throws, you are passing a non-serialisable value across the boundary and will get a runtime mismatch in the browser.

Failure Modes

1. State declared at the route level defeats islands mode. If a route component itself calls createSignal or createEffect, SolidStart must hydrate the route to keep that state live, pulling the whole tree back into hydration. The symptom is a route-wide client chunk where you expected only island chunks. Fix: move all reactivity into island leaf components and keep route/layout components purely presentational.

// WRONG — signal at route level forces route-wide hydration.
export default function Page() {
  const [open, setOpen] = createSignal(false); // ← hydrates the whole route
  return <Layout></Layout>;
}

// RIGHT — the signal lives inside a small island; the route stays static.
export default function Page() {
  return <Layout><DisclosureIsland /></Layout>;
}

2. Hydration mismatch from server/client divergence. Rendering Date.now(), Math.random(), or locale-dependent formatting inside an island produces different HTML on server and client; Solid then discards the server DOM and re-creates the subtree, erasing the benefit and sometimes flashing content. Fix: make island render deterministic — compute the volatile value on the server, pass it as a prop, or move the widget to clientOnly so there is no server output to diverge from.

3. Non-serialisable props across the boundary. Passing a function, Map, Date, or class instance as an island prop fails serialisation and the island either throws on hydration or arrives with undefined props. Fix: pass plain JSON-serialisable data and reconstruct richer types inside the island, following cross-boundary prop passing.


Reads Outside the Island Never Update Left panel: an island subtree owns a signal; a sibling node outside the island reads the signal's value once at render time and is never re-run, so it displays a stale value forever. Right panel: the same value is moved into an external store that both the island and the sibling subscribe to, so both update when it changes. A signal only drives the nodes inside its own island STALE island: filter panel owns count() signal updates on every change header badge outside the island frozen at 0 The badge was server-rendered from the same value, so it looks correct on load and drifts afterwards — which is why this ships to production unnoticed. CORRECT island: filter panel writes to the store island: header badge subscribes to the store shared store — the only thing both islands import Two small islands and one store beat one large island.

Frequently Asked Questions

How is Solid hydration cheaper than React hydration?

Solid compiles JSX into direct DOM instructions rather than a virtual DOM tree. Hydration walks the existing server-rendered DOM, wires reactive signals to the exact text nodes and attributes they control, and stops — there is no tree reconciliation or diff. The cost is proportional to the number of reactive bindings, not the size of the DOM.

What does islands mode change in SolidStart?

With islands mode enabled, SolidStart treats page markup as static by default and only ships and hydrates the components you designate as islands. Instead of hydrating the whole route, the client runtime activates discrete interactive regions, so a mostly static page loads almost no JavaScript.

When should I use clientOnly in SolidStart?

Use clientOnly for components that depend on browser-only APIs or third-party libraries that cannot render on the server — maps, canvas widgets, or anything reading window/localStorage at render time. clientOnly skips SSR for that subtree and mounts it only in the browser, avoiding hydration mismatches.

← Back to Framework-Specific Islands & Streaming SSR