Next.js — worker islands

@atolljs/react-island's <Island> is an ordinary React component — inside 'use client' it mounts a worker-hosted React tree into an App Router page. Reconciliation, rendering, and that subtree's state churn run off the main thread; Next keeps the shell, routing, and streaming.

The boundary

ChartsIsland.tsxfrontend
// src/app/dashboard/ChartsIsland.tsx — a client component is the boundary
'use client';

import { Island } from '@atolljs/react-island';
import type { ChartsApp } from './charts.worker'; // type only!

const renderWorker = () =>
  new Worker(new URL('./charts.worker.ts', import.meta.url), { type: 'module' });

export function ChartsIsland() {
  return (
    <Island
      worker={renderWorker}
      app="charts"                 // registry name — or the component for props inference
      props={{ width: 520 }}
      onEvent={(name, payload) => console.log('island event', name, payload)}
      slots={{ toolbar: <ToolbarButton /> }} // main-thread React into worker slots
    />
  );
}
page.tsxfrontend
// src/app/dashboard/page.tsx — a plain server component composes it
import { ChartsIsland } from './ChartsIsland';

export default function Page() {
  return (
    <main>
      <h1>Operations</h1>          {/* server-rendered, streamed instantly */}
      <ChartsIsland />             {/* renders in a worker, hydrates async */}
    </main>
  );
}

SSR is honest: the server renders the empty container div, and the worker + first op batch mount in an effect after hydration. The island's content isn't in the server HTML — same trade-off as any client-side render, scoped to the island instead of the page.

What runs where

charts.worker.tsfrontend
// src/app/dashboard/charts.worker.ts — the worker entry + what goes inside
import { useEffect, useState } from 'react';
import { defineReactPolyWorker } from '@atolljs/react-island/worker';
import { emit } from '@atolljs/islands/worker';

// Ordinary React — hooks, state, effects all run in the worker. No DOM
// access, serializable props, emit() is the island → shell channel.
export function ChartsApp({ width = 480 }: { width?: number }) {
  const [points, setPoints] = useState<number[]>([]);
  useEffect(() => {
    const id = setInterval(() =>
      setPoints((p) => [...p.slice(-59), Math.random() * 100]), 250);
    return () => clearInterval(id);
  }, []);
  return (
    <div className="chart" style={{ width }}>
      <h3>live series — {points.length} pts</h3>
      <button onClick={() => emit('reset', { at: Date.now() })}>reset</button>
    </div>
  );
}

export const chartsWorker = defineReactPolyWorker({
  apps: { charts: ChartsApp },
});

The worker entry — definePolyWorker/islandApp plus a proxy-DOM document — owns the React reconciler; ops stream to the main thread which applies them to real DOM. Props and events cross as structured clones; large data belongs in shared memory. slots are the escape hatch back to the main thread — portals into data-atoll-slot anchors for things workers can't do (canvas libraries, maps, Monaco, third-party widgets). Full contract: Islands and React → Islands.

Why it matters under Next.js

App Router already thinks in islands (server components + client boundaries) — worker islands push that further: a 50k-row virtual table or a streaming chart can re-render at full tilt without a single main-thread commit. The page's interactivity budget stays flat no matter how hot the subtree runs.

Deployment note: this is a browser worker — it works in static export too (output: 'export'), needing only COOP/COEP headers if it uses shared memory. Nothing here requires the Node runtime, unlike the server-side pages in this section.