reactolith
v1.2.1
Published
Use HTML on the server to compose your react application.
Maintainers
Readme
⚡️ reactolith
Hotwire Turbo for React. Render and morph a React app from server-generated HTML — Twig, ERB, Blade, Jinja, anything that prints strings.
reactolith lets you write React components directly in HTML so your backend stays in charge of routing, templates, permissions, and URLs, while React stays in charge of interactivity. It is not Inertia for the masses — it is a different approach: instead of shipping JSON page props and re-rendering a top-level component on every navigation, reactolith fetches the next HTML page, morphs the existing React tree in place, and preserves component state across navigation, form submits, and live server pushes.
📖 Full documentation: https://reactolith.github.io/
Why reactolith exists
I built reactolith because I wanted Hotwire Turbo — but for React.
Inertia.js is the closest thing in the React world, and it is great, but it is fundamentally a different model: Inertia replaces the page-level component on every visit. There is no equivalent of Turbo's morph — you cannot keep a sidebar's collapsed state, a video playing, or a half-typed form alive across a backend-driven navigation. The whole "page" is a single JSON-fed component that gets swapped out.
reactolith takes the Turbo approach instead. The server returns HTML, not JSON. reactolith parses that HTML, diffs it against the live React tree, and updates only what changed — using React's own reconciler. Component state, focus, scroll, open dialogs, mounted iframes: they all survive.
That tiny difference unlocks something bigger: it makes the Majestic Monolith a serious option again for teams who want React. You can keep one Rails / Symfony / Laravel / Django app — one router, one auth layer, one set of URL helpers, one deployment — and still ship a sticky, app-like React frontend on top of it. No SPA-shaped backend. No GraphQL gateway. No /api/v2 to keep in sync with the UI. Templates render HTML; reactolith makes that HTML interactive.
If you've ever looked at the Hotwire stack and wished you could use React components inside it, reactolith is that.
Install
npm install reactolith react react-domRequires Node 18+ and React 19.
A 30-second tour
<!-- index.html (rendered by your backend) -->
<div id="reactolith-app">
<h1>Hello world</h1>
<my-button>Click me</my-button>
</div>// src/main.tsx
import { App } from "reactolith";
import { MyButton } from "./components/my-button";
new App(({ is }) => (is === "my-button" ? MyButton : null));Any tag name with a hyphen (<my-button>) is resolved to a React component;
everything else renders as a native element.
For larger apps, point createLoader at a folder of components and skip the
per-tag wiring:
import { App, createLoader } from "reactolith";
new App(createLoader({
modules: import.meta.glob("./components/ui/*.tsx"),
prefix: "ui-",
}));Highlights
- 🔌 Backend-agnostic — works with any backend (Symfony, Rails, Laravel, Django, …)
- 🔄 Morphing navigation — like Turbo's
morph: the tree is diffed in place, React state is preserved across link clicks and form submits - 📋 Forms — modify forms dynamically (server can add/remove fields on a checkbox click) without losing input state or focus
- 📡 Realtime — Mercure SSE pipes server-pushed HTML through the same render path
- 🧠 Scroll restoration — browser-like behavior across SPA-style navigations
- 🧩 IDE autocomplete — generate web-types for JetBrains/VS Code so
<my-button>autocompletes like a native element - 🪶 No new backend layer — no JSON page-prop contract to maintain, no shadow API; your existing controllers and templates are the API
See the docs for the full feature list, API reference, and end-to-end guides.
Server-side rendering
For static-site generators or progressive enhancement, import a server-safe
renderToString from reactolith/server:
import { JSDOM } from "jsdom";
import { renderToString } from "reactolith/server";
import { resolveComponent } from "./resolve-component";
const dom = new JSDOM(`<div id="root"><my-button>Hello</my-button></div>`);
const html = renderToString(
dom.window.document.getElementById("root")!,
resolveComponent,
);Router and Mercure side effects are skipped on the server because both rely on
useEffect. See the SSR guide
for the full walkthrough.
Reactolith vs. the alternatives
If you came here looking for an alternative to one of these, head to the side-by-side comparisons:
- reactolith vs Inertia.js — what Inertia gives you, what it gives up, and where reactolith fits
- reactolith vs Hotwire Turbo — same backend-driven philosophy, but for React component trees
Examples
End-to-end example apps live in /examples:
- symfony-multistep-form — Symfony 8
FormFlowwith a complete shadcn/ui form theme that covers every native Symfony form field type. The Twig view is one line ({{ form(form) }}); every visual decision lives in a single form theme file.
Docs map
| Topic | Link | |---|---| | Why reactolith | Comparisons | | Install & set up | Installation | | First app | Quick Start | | Mental model | How It Works | | Props & slots | Props · Slots | | Forms & validation | Forms | | Scroll | Scroll Restoration | | Realtime | Mercure | | SSR | Server-Side Rendering | | Tooling | Web Types · API Cheatsheet |
The docs site lives in /docs and is itself built with reactolith — every page is plain HTML hydrated into React.
Development
npm install
npm run build # dist/index.{mjs,cjs}, dist/index.d.ts, CLI bundle
npm test # vitest run
npm run typecheck # tsc --noEmit
npm run lint # eslint src testsprepublishOnly runs npm run build so published artifacts always come from a fresh build.
Contributions welcome — open an issue or PR. See CONTRIBUTING.md for the local-checks workflow, docs-site setup, and commit conventions.
