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.

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.

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.


← Back to Framework-Specific Islands & Streaming SSR