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
// 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
/>
);
}// 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
// 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.