Automatic code-splitting for React event handlers and components.
Phantom is a build plugin that analyzes your React code, extracts event handlers into lazy-loaded chunks, and wraps below-fold components in React.lazy + Suspense — all automatically, with zero config changes to your components.
npm install phantom-buildPhantom runs at build time (Vite, Webpack, or Rspack) and does two things:
1. Handler extraction — Event handlers that touch browser APIs (window, document, localStorage, etc.) are extracted into separate chunks and loaded on-demand when the user first interacts.
2. Lazy component wrapping — Child components below the fold in route-level pages are automatically wrapped in React.lazy() + <Suspense>, so they don't block initial page load.
Your source code stays unchanged. Phantom transforms the output at build time.
Plus: RSC migration analysis. Separate from the build, the read-only phantom rsc <dir> command produces a whole-codebase React Server Components migration map: per-file server-eligible vs must-be-client verdicts, the minimal 'use client' frontier, the client blast radius, and an honest realizable-server estimate. See RSC Readiness.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import phantom from 'phantom-build/vite';
export default defineConfig({
plugins: [
phantom(),
react(),
],
});// webpack.config.js
import phantom from 'phantom-build/webpack';
export default {
// ...
module: {
rules: [
{
test: /\.tsx?$/,
use: {
loader: 'ts-loader',
options: { transpileOnly: true },
},
exclude: /node_modules/,
},
],
},
plugins: [
phantom(),
],
};// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import phantom from 'phantom-build/rspack';
export default defineConfig({
plugins: [pluginReact()],
tools: {
rspack: {
plugins: [phantom()],
},
},
});That's it. Run your build and Phantom handles the rest.
Given this component:
export function InteractiveComponent() {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = (e: React.MouseEvent) => {
window.location.href = `/product/${e.target.dataset.id}`;
};
const handleScroll = () => {
window.scrollTo(0, 0);
localStorage.setItem('scrolled', 'true');
};
return (
<div onClick={handleClick}>
<button onClick={handleScroll}>Top</button>
</div>
);
}Phantom produces:
- Client code — handlers are replaced with lightweight stubs that lazy-load the real logic on first click
- Chunk modules — handlers from the same source file are grouped into a single chunk module, loaded on demand
The stub calls e.preventDefault() and e.stopPropagation() synchronously (before the import), so critical event behavior is never delayed. The heavy logic (window.location.href, localStorage, etc.) loads asynchronously.
Given a route-level page component:
import { CartItems } from './CartItems';
import { OrderSummary } from './OrderSummary';
import { PaymentForm } from './PaymentForm';
import { AddressForm } from './AddressForm';
import { PromoCode } from './PromoCode';
export default function CheckoutPage({ order, user }) {
const [showPromo, setShowPromo] = useState(false);
return (
<CartProvider cartId={order.cartId}>
<CartItems items={order.items} />
<OrderSummary totals={order.totals} />
<PaymentForm userId={user.id} />
<AddressForm userId={user.id} />
{showPromo && <PromoCode cartId={order.cartId} />}
</CartProvider>
);
}Phantom transforms this to:
import { CartItems } from './CartItems';
import { OrderSummary } from './OrderSummary';
const PaymentForm = lazy(() =>
import('./PaymentForm').then(m => ({ default: m.PaymentForm }))
);
const AddressForm = lazy(() =>
import('./AddressForm').then(m => ({ default: m.AddressForm }))
);
const PromoCode = lazy(() =>
import('./PromoCode').then(m => ({ default: m.PromoCode }))
);
export default function CheckoutPage({ order, user }) {
const [showPromo, setShowPromo] = useState(false);
return (
<CartProvider cartId={order.cartId}>
{/* Kept static: above fold */}
<CartItems items={order.items} />
<OrderSummary totals={order.totals} />
{/* Lazy: adjacent siblings share one Suspense boundary */}
<Suspense fallback={null}>
<PaymentForm userId={user.id} />
<AddressForm userId={user.id} />
</Suspense>
{/* Lazy: conditionally rendered, own boundary */}
{showPromo && (
<Suspense fallback={null}>
<PromoCode cartId={order.cartId} />
</Suspense>
)}
</CartProvider>
);
}What stays static (not lazified):
- Components above the fold (positions 0-1 in the JSX tree)
- Context providers (must hydrate before consumers)
- Tiny components (source files under 512 bytes — stub overhead would exceed savings)
What gets lazified:
- Components below the fold (position 2+) with meaningful source size
- Conditionally rendered components (
{flag && <Component />})
phantom({
// Confidence threshold for handler extraction (0.0 - 1.0)
// Lower = more aggressive extraction. Default: 0.8
confidenceThreshold: 0.8,
// Minimum handler body size in bytes to extract. Default: 200
// Handlers smaller than this are left inline (stub would be bigger).
minHandlerSize: 200,
// Enable/disable lazy component wrapping. Default: true
enableLazy: true,
// Preload strategy for handler chunks. Default: 'none'
// 'idle' injects requestIdleCallback modulepreload for all chunks.
// 'none' loads chunks on-demand only (best for Lighthouse scores).
preloadStrategy: 'none',
// Output path for the build manifest. Default: "phantom.manifest.json"
manifestPath: 'phantom.manifest.json',
// Suppress console output during build. Default: false
silent: false,
// Cerebras API key for LLM-assisted optimization (optional)
cerebrasApiKey: process.env.CEREBRAS_API_KEY,
// Cerebras model ID. Default: "qwen-3-32b"
cerebrasModel: 'qwen-3-32b',
// SSR mode: skip all transforms for server builds. Default: false
ssr: false,
// Automatically detect SSR boundaries for components.
// 'auto': Analyze and add results to manifest (report-only)
// 'annotate': Prepend "use client" to ClientOnly modules
// false: Disabled (default)
ssrBoundaries: false,
})Phantom supports custom SSR setups where you run separate client and server builds. React.lazy() throws when used with renderToString(), so the server bundle needs original code with synchronous imports.
The solution: run Phantom with ssr: true for your server build. This makes the plugin a complete no-op — your server bundle gets untouched source code.
// vite.config.server.ts
import phantom from 'phantom-build/vite';
export default defineConfig({
plugins: [
phantom({ ssr: true }),
react(),
],
});// webpack.config.server.js
import phantom from 'phantom-build/webpack';
export default {
target: 'node',
plugins: [
phantom({ ssr: true }),
],
};// rsbuild.config.server.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import phantom from 'phantom-build/rspack';
export default defineConfig({
plugins: [pluginReact()],
tools: {
rspack: {
plugins: [phantom({ ssr: true })],
},
},
});Most custom SSR setups (e.g., .NET + React, Express + React) use two bundler configs:
webpack.config.client.js → phantom() // full transforms
webpack.config.server.js → phantom({ ssr: true }) // no-op
The client build produces lazy-loaded handler chunks and React.lazy wrappers. The server build produces a synchronous bundle for renderToString() with all components inline.
Why not strip just React.lazy? Selectively removing lazy transforms while keeping handler extraction would still inject $p runtime imports and dynamic import() calls into the server bundle. A clean no-op is simpler, produces the smallest server bundle, and avoids any SSR-incompatible code paths.
Phantom's heuristics handle ~80% of cases correctly. For the remaining 20% that require judgment (grouping related components, choosing prefetch strategies for ambiguous cases), you can enable LLM refinement:
# .env
CEREBRAS_API_KEY=your_key_herephantom({
cerebrasApiKey: process.env.CEREBRAS_API_KEY,
})The LLM call is batched across modules (one API call per build), results are cached to disk (*.lazy-cache.json), and failures fall back to heuristics silently. The LLM never blocks your build.
Phantom includes a CLI for analyzing individual files:
npx phantom analyze src/components/CheckoutPage.tsxOutput:
Phantom Analysis: src/components/CheckoutPage.tsx
════════════════════════════════════════════════════════════
Name Class Conf Extracted?
────────────────────────────── ──────────────────── ────── ──────────
handleTogglePromo EventHandler 0.95 ✓ yes
→ JSX event handler prop: onClick
→ Browser API: window referenced
Segments: 5
Threshold: 0.8
Chunks extracted: 1
seg_abc123 (0.1 KB)
Lazy Components:
Name Strategy Group
───────────────────────── ──────────── ───────────────
PaymentForm viewport group_0
AddressForm viewport group_0
PromoCode interaction (solo)
Kept Static:
CartItems → Position 0 in route component — above fold
OrderSummary → Position 1 in route component — above fold
CartProvider → Context provider — must hydrate before consumers
phantom analyze <file> [options]
Options:
--threshold <number> Confidence threshold for extraction (default: 0.8)
--min-handler-size <number> Min handler bytes to extract (default: 200)
--help, -h Show this help message
phantom rsc <dir> runs a whole-codebase React Server Components migration map over an existing React app. It is read-only and report-only, so it never edits your source. It reuses Phantom's SSR-boundary classifier and adds cross-module graph analysis to answer the questions a per-file 'use client' linter cannot:
- which files are server-eligible vs must-be-client, and why
- the minimal
'use client'frontier to add (the topmost client files; everything they import inherits the directive) - the client blast radius along the import graph (server-eligible files trapped on the client because a client file imports them)
- an honest realizable-server estimate (files and bytes), always printed next to the import-edge resolution coverage so the number is interpretable
- rescue opportunities (a trapped server file that one client parent could pass as
childrento keep on the server) - shallow serialization hazards (a function or class instance passed across a server-to-client boundary)
npx phantom rsc src/
npx phantom rsc src/ --json # machine-readable RscReport
npx phantom rsc src/ --markdown rsc.md # also write a markdown reportExample, run against the shadcn-admin reference app:
155 component files · import graph 100% resolved
Server-eligible: 81 · Realizable after blast radius: 23 (11.3% of component bytes)
'use client' frontier: 24 files to mark
Read that as follows: 81 files have no per-file blocker to becoming a Server Component, but once client-ness propagates across imports only 23 can actually stay on the server, about 11% of component bytes. That gap is the whole point. The tool reports the real, graph-aware migration surface, not the optimistic per-file count.
Validated on real codebases (non-reference apps anonymized), all at 99.8–100% import-edge resolution:
| Codebase | Component files | Server-eligible | Realizable (server) | % of bytes | 'use client' frontier |
|---|---|---|---|---|---|
| Production Sitecore JSS app | 258 | 79 | 36 | 8.4% | 108 |
| Shared UI component library | 113 | 49 | 38 | 41.0% | 42 |
| E-commerce storefront (Next.js) | 238 | 144 | 120 | 29.8% | 56 |
| shadcn-admin (reference) | 155 | 81 | 23 | 11.3% | 24 |
| bulletproof-react (reference) | 54 | 17 | 9 | 12.1% | 18 |
The spread is the point: an interactive enterprise app realizes only ~8% of its component bytes as server (so the value is the frontier and the rescue hints, not a server win), while a static-heavy storefront or a presentational component library realizes 30–41%.
Honest caveats:
- It is a map, not a transform. v1 produces a report only; there is no codemod.
- Accuracy is conservative by design. When unsure, it classifies a file
must-be-client(safe) rather thanserver-eligible(which could break a migration). AuseStatecomponent is "SSR-safe" for first paint but is stillmust-be-clientfor RSC, and the tool treats it that way. - The realizable-server slice is often small on real apps. The interactive and vendor shell dominates, which is expected. The value is the accurate map plus the rescue and hazard hints, not a large server-component win.
- The realizable-server figure is only as trustworthy as the import-edge resolution, which is always printed beside it. Below 90%, the tool prints a lower-confidence warning, because unresolved edges under-propagate client-ness and over-report server-eligibility.
The analysis core is framework-agnostic; the advice wording is Next-first. Import resolution handles relative imports, tsconfig path aliases (@/...), and one-hop barrel (index.ts) re-exports.
Each build produces a phantom.manifest.json describing all extractions:
{
"version": 1,
"entries": [
{
"segmentId": "seg_01b6063a6ad3",
"sourceFile": "/src/InteractiveComponent.tsx",
"virtualId": "phantom:seg_01b6063a6ad3.chunk.js",
"name": "handleClick",
"kind": "handler"
},
{
"segmentId": "lazy_PaymentForm",
"sourceFile": "/src/CheckoutPage.tsx",
"virtualId": "./PaymentForm",
"name": "lazy(PaymentForm)",
"kind": "lazy"
}
],
"stats": {
"totalModulesProcessed": 42,
"totalSegmentsExtracted": 12
}
}Phantom processes each module through a 5-phase pipeline:
- Parse — OXC parser produces an ESTree AST; eslint-scope resolves variable bindings
- Classify — Three-pass analysis (taint, purity, boundary detection) classifies each function as
EventHandler,PureComputation,ClientInteractive,Shared, orAmbiguous - Lazy Detection — Identifies child component imports that should be
React.lazywrapped, using JSX position, conditionality, and cross-module component profiles - Extract — Rewrites the AST: handler bodies move to virtual chunk modules, component imports become
lazy()declarations, JSX gets<Suspense>wrappers - LLM Refinement (optional) — Batched API call refines prefetch strategies and Suspense grouping for edge cases
| Classification | Criteria | Action |
|---|---|---|
EventHandler |
Used exclusively as JSX event prop (onClick, onSubmit, etc.), references browser APIs |
Extracted to lazy chunk |
PureComputation |
No browser globals, no side effects, deterministic | Kept inline |
ClientInteractive |
Browser APIs but not an event handler (effects, observers) | Kept inline |
Shared |
Mix of pure and impure, or component render function | Kept inline |
Ambiguous |
Below confidence threshold | Kept inline (conservative) |
| Strategy | When Used | Behavior |
|---|---|---|
viewport |
Below-fold components | Load when element enters viewport (IntersectionObserver) |
interaction |
Conditionally rendered components | Load on user interaction that triggers render |
idle |
Components with effects | Load during requestIdleCallback |
immediate |
LLM-determined critical paths | Load immediately after initial render |
The phantom-build/runtime module provides $p (the lazy handler loader), which:
- On first invocation, dynamically imports the handler chunk
- Caches the loaded function for instant subsequent calls
- Deduplicates concurrent imports (rapid clicks don't trigger multiple fetches)
- Cleans up on failure so retries work
import { $p } from 'phantom-build/runtime';
// Generated stub (you never write this — Phantom does):
const handleClick = (e) => {
e.preventDefault(); // synchronous — runs immediately
$p(
() => import('phantom:seg_abc123.chunk.js'),
'seg_abc123',
e, // forwarded args
inputRef, // captured variables
);
};Phantom resolves through barrel files automatically. If your components use index re-exports:
// components/index.ts
export { PaymentForm } from './PaymentForm';
export { AddressForm } from './AddressForm';// CheckoutPage.tsx
import { PaymentForm, AddressForm } from './components';The generated lazy() calls will target the actual component modules (./components/PaymentForm), not the barrel file — producing optimal chunk splitting.
Tested against shadcn-admin (11k+ stars) — a production-grade admin dashboard built with React 19, Vite 6, TanStack Router, and Shadcn UI.
Phantom reduces the JavaScript that loads when navigating to each page. Shared dependencies (React, Radix, etc.) are identical between builds — only route-specific code changes.
| Route | Baseline | Phantom | Reduction |
|---|---|---|---|
Main layout (_authenticated) |
336 KB | 10.7 KB | −97% |
| Settings → Account | 52.9 KB | 1.1 KB | −98% |
| Auth → OTP | 12.6 KB | 1.2 KB | −90% |
| Settings → Notifications | 7.1 KB | 1.0 KB | −86% |
| Settings → Appearance | 4.3 KB | 1.0 KB | −75% |
| Total (29 routes) | 480 KB | 77.5 KB | −84% |
57 interaction handlers (modals, dialogs, dropdowns) are deferred to on-demand chunks totaling 5.3 KB. These load only when the user actually clicks.
| Metric | Baseline | Phantom | Change |
|---|---|---|---|
| Performance Score | 80 | 80 | 0 |
| Total Blocking Time | 63 ms | 4 ms | −94% |
| First Contentful Paint | 3.19 s | 3.18 s | — |
| Largest Contentful Paint | 4.16 s | 4.11 s | −1% |
| Total Byte Weight | 479 KB | 478 KB | — |
Total Blocking Time measures how long the main thread is locked and unable to respond to user input. Phantom reduces it by 94% because route chunks contain far less JavaScript to parse and compile — with zero regression in performance score or byte weight.
# Clone the repo and install
git clone https://github.com/Phoenixrr2113/phantom.git
cd phantom && npm install && npm run build
# Route chunk comparison
node benchmarks/compare-routes.mjs
# Lighthouse A/B comparison
node benchmarks/lighthouse-compare.mjs --runs=5- Node.js >= 18
- React 16.6+ (for
React.lazyandSuspense) - Vite, Webpack, or Rspack (Rsbuild)
- For SSR: any custom server setup (Express, .NET, etc.) — not framework-specific
MIT