Migrating to ShadowJS
Choose a migration path for your React, React Router, Preact, Next.js, Solid, shadcn/ui or Tailwind app, then follow an incremental order that keeps the old app running.
ShadowJS is a from-scratch UI framework: JSX compiled to real DOM operations, signals instead of re-rendering, and no virtual DOM. Migrating a production app is a rewrite of the view layer, so plan it as a series of small cutovers, not one switch. This page picks the right guide for your stack, gives an order that works, and lists what to check before you start. Each guide pairs the original code with its ShadowJS equivalent.
Pick your guide#
Choose what you are coming from to see where to start.
| You are coming from | Read | What changes most |
|---|---|---|
| React | React to ShadowJS | Components run once: no re-renders, no dependency arrays, no useMemo/useCallback. State is read by calling it (count()) |
| React Router or Remix | React Router to ShadowJS, then React to ShadowJS | Links, the outlet and the navigation hooks map one to one (a codemod does them); the route table becomes defineRoute objects, and useLoaderData() returns an accessor |
| Preact | Preact to ShadowJS, then React to ShadowJS | The same model as React: components run once and state is read by calling it. render takes a function, @preact/signals becomes useSignal with accessors, and preact/compat is not needed |
| Next.js | Next.js to ShadowJS, then React to ShadowJS | The app becomes a Dusk app: file routes and loaders replace pages and server components, server functions replace server actions, API routes replace route handlers, and middleware moves to entry-server.ts. Dusk provides its own ISR, image optimization and font pipeline; React Server Components have no equivalent |
| Solid or SolidStart | Solid to ShadowJS | Mostly renames (createSignal to useStore) plus a few deliberate differences. SolidStart's server features map to Dusk's server functions and API routes; see the Next.js guide for the pattern |
| shadcn/ui or Radix UI | shadcn/ui and Radix to ShadowJS, and the UI kit | These libraries are React-only, so nothing is ported at run time: stock shadcn components come from @shadow-js/ui with the same class lists, and custom Radix components are rebuilt on @shadow-js/primitives |
| Socket.io | Socket.io to @shadow-js/realtime | Rooms and namespaces become channels with rules, acknowledgment callbacks become promises and replies, and a reconnect resumes from the last message number |
| Tailwind CSS | Tailwind to ShadowJS | CSS is unchanged; class takes arrays and objects, which replaces clsx and cn helpers |
| Convex and Clerk | The data and auth sections of Next.js to ShadowJS | @shadow-js/convex and @shadow-js/clerk mirror the React packages with accessors instead of values |
What to check before you start#
Compare the list below with your app. Anything you rely on that appears here needs an interim plan, because it does not exist in ShadowJS today:
- React Server Components, ISR,
revalidate, edge middleware, parallel and intercepting routes: not available. Pages render on the server and in the browser, data comes from loaders and server functions, and caching is yours. See What has no equivalent yet. - File-based routing, API routes, server functions, middleware: available through Dusk
(Dusk overview).
routes/replacesapp/; the Next.js guide has the full mapping. next/image,next/font: plainimgand CSS.- React-only component libraries (Radix, shadcn/ui, Material UI, Chakra, React Query, React Hook Form): each
needs a replacement; there is no compatibility layer that runs React components inside ShadowJS. ShadowJS ships
replacements for three of them:
@shadow-js/uifor shadcn/ui (on@shadow-js/primitivesfor Radix),@shadow-js/formsfor React Hook Form, and@shadow-js/i18nfor next-intl, react-intl and i18next. Material UI and Chakra have none. - Tooling versions:
@shadow-js/viterequires Vite 7 or 8 (^7.0.0 || ^8.0.0), the packages require Node 22.12 or newer, and the scaffold uses TypeScript 7.
Keep the old app running#
By default the Vite plugin compiles every .jsx and .tsx file outside node_modules and sets Vite's own JSX import
source to @shadow-js/web; type checking uses jsxImportSource: "@shadow-js/web". Putting React and ShadowJS
components in one Vite project is not a tested setup, so keep two projects and choose how users move between them. All three options below are deployment
choices; the repository ships no tooling for them.
| Strategy | How it works | Use it when |
|---|---|---|
| Route by route | Run the new app next to the old one behind one host, and route paths to one or the other (a reverse proxy or your host's rewrite rules). Links between the apps are ordinary anchors, so moving between them reloads the page | The app has clear sections. This is the default recommendation |
| Island | Build a ShadowJS widget as its own bundle and call its render into an element that the old page owns. See below |
You need one component to appear inside an old page |
| Rewrite | Build the new app from scratch and switch once | The app is small, or the old UI cannot be hosted anywhere |
The shared pieces are the services, not the UI. Convex and Clerk are hosted: the old app and the new one can use the same Convex deployment and the same Clerk instance (the same publishable key), so data and users stay common while the UI moves.
An island inside an old page#
render(code, container) appends the component output to any element and returns a dispose function that removes
exactly the nodes it inserted. An existing child of the container is left alone, so the element can belong to another
framework. The ShadowJS side of an island is:
import { render } from "@shadow-js/web";
export function Widget() {
return <p>Hello from ShadowJS</p>;
}
/** The only function the host page calls. It returns the cleanup. */
export function mountWidget(element: HTMLElement): () => void {
return render(() => <Widget />, element);
}
The host page (here React, which cannot run in this documentation suite, so the block is illustrative) loads the bundle and ties the island's lifetime to a component:
import { useEffect, useRef } from "react";
import { mountWidget } from "/widgets/shadow-widget.js";
export function WidgetSlot() {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
if (element.current === null) return;
return mountWidget(element.current);
}, []);
return <div ref={element} />;
}
An order that works#
Port leaf components first, then move the state and routing they depend on. Verify the affected user flows after each step.
- Set up the project and one route. Create the Vite project with the ShadowJS plugin and render one page of the old app's smallest section. Deploy it next to the old app behind a path rule. This proves the build, the hosting and the backend connection before any real work.
- Port presentational components. Components that take props and render markup change the least: replace
classNameif you like (bothclassandclassNamework), read props asprops.x, and dropmemo, keys andforwardRef. - Port state and effects. Replace each
useStatewithuseStore, and eachuseEffectdependency array with automatic tracking. The React guide shows the stale-closure class of bug disappearing. - Port forms. React's
onChangeworks unchanged: on text inputs and textareas it runs on every edit, on selects and checkboxes when the choice is committed. Controlled inputs arevalue={signal()}plusonInputoronChange. If the app uses React Hook Form, move to@shadow-js/forms:useForm({ resolver: standardSchema(schema) })takes the same Zod, Valibot or ArkType schemas, and the mapping table is in the React guide. - Port the data layer. Replace React Query or SWR with
useResource(check the feature table first) or with Convex'suseQueryanduseMutation. - Port routing and layouts. Convert the route tree to files under
src/routeswith loaders,pending,errorandnotFound(Next.js guide). - Port auth. Swap
@clerk/nextjsor@clerk/clerk-reactfor@shadow-js/clerk. - Move the server last. Server functions, API routes, middleware and server rendering come with Dusk; see Server rendering and hydration.
- Cut over section by section. Move the path rule for one section to the new app, watch it, and keep the old section deployable until you are sure.
Do not start with the router, the global store or the auth layer: they touch everything, and a mistake there shows up everywhere at once.
Checklist#
- Compared the app against What to check before you start and chosen an interim plan for every gap.
- Chosen a strategy (route by route, island or rewrite) and where the two apps are hosted.
- Created a separate Vite project with
@shadow-js/vite,jsxImportSourceset to@shadow-js/web, on Vite 8 and Node 22.12 or newer. - Shipped one small route end to end, connected to the same backend as the old app.
- Ported components leaf-first, before the router, store and auth.
- Replaced React-only libraries (Radix, shadcn/ui, React Query, form libraries) with a ShadowJS-side equivalent from the guides.
- Moved environment variables from
NEXT_PUBLIC_orREACT_APP_toVITE_. - Run both apps against the same staging data and compared behavior on every migrated route.
- Cut traffic over section by section, with the old section kept deployable until the new one has held up.
- Removed the old section, its dependencies and its tests only after the cutover held.