reactolith
v1.1.2
Published
Use HTML on the server to compose your react application.
Readme
⚡️ reactolith
Proof of Concept – This project is still experimental and not ready for production.
reactolith lets you write React components directly in HTML — making it possible to render and hydrate a React app using server-generated HTML from any backend (e.g. Symfony/Twig, Rails, Laravel, Django, etc.).
✨ Instead of manually wiring React components everywhere, just return HTML from your backend and reactolith will transform it into a live, interactive React application.
It even includes a built-in router that intercepts link clicks and form submissions, fetches the next page via AJAX, and updates only what changed — keeping React state intact between navigations.
🚀 Features
- 🔌 Backend-agnostic – Works with any backend (Symfony, Rails, Laravel, etc.)
- 🛠 Use existing backend helpers (Twig path functions, permission checks, etc.)
- 🔄 State preserved across pages – No resets on navigation
- 📋 Form support – Modify forms dynamically (e.g., add buttons on checkbox click) without losing state or focus
- 🪶 Lightweight – Just a few lines of setup, no heavy dependencies
- 📡 Real-time updates – Works with Mercure Server-Sent-Events to push updates from the backend to the frontend
📦 Installation
npm install reactolithSince react and react-dom are peer dependencies, make sure to also install them:
npm install react react-domHow It Works
- Initial Load: Symfony renders HTML with Twig,
reactolithhydrates it into React components - Navigation: Clicking links fetches new HTML via AJAX, React reconciles the differences
- Real-time: Mercure pushes HTML updates from server, UI updates automatically
- State Preserved: React component state survives both navigation and real-time updates
💡 Usage
Your backend returns simple HTML:
<html lang="en">
<body>
<div id="reactolith-app">
<h1>Hello world</h1>
<ui-button type="primary">This will be a shadcn button</ui-button>
</div>
</body>
</html>Your frontend mounts the reactolith app:
// app.ts
import loadable from '@loadable/component'
import { App } from 'reactolith'
const component = loadable(
async ({ is }: { is: string }) => {
return import(`./components/ui/${is.substring(3)}.tsx`)
},
{
cacheKey: ({ is }) => is,
// Since shadcn files don’t export a default,
// we resolve the correct named export
resolveComponent: (mod, { is }: { is: string }) => {
const cmpName = is
.substring(3)
.replace(/(^\w|-\w)/g, match => match.replace(/-/, '').toUpperCase())
return mod[cmpName]
},
}
)
// Uses the HTML element with id="reactolith-app" as root
new App(component)🎨 Example with Custom Root Component & Selector
// app.ts
import loadable from '@loadable/component'
import { App } from 'reactolith'
import { AppProvider } from './providers/app-provider.tsx'
const component = loadable(
async ({ is }: { is: string }) => import(`./components/${is}.tsx`),
{ cacheKey: ({ is }) => is }
)
new App(component, AppProvider, '#app')// providers/app-provider.tsx
import React, { ElementType } from "react"
import { App, RootComponent } from "reactolith"
import { RouterProvider } from "react-aria-components"
import { ThemeProvider } from "./theme-provider"
export const AppProvider: React.FC<{
app: App
element: HTMLElement
component: ElementType
}> = ({ app, element, component }) => (
<React.StrictMode>
<RouterProvider navigate={app.router.navigate}>
<ThemeProvider>
<RootComponent element={element} component={component} />
</ThemeProvider>
</RouterProvider>
</React.StrictMode>
)🔄 Navigation Without Losing State
When navigating, reactolith fetches the next HTML page and applies only the differences using React’s reconciliation algorithm.
👉 This means component state is preserved (e.g., toggles, inputs, focus).
<!-- page1.html -->
<div id="reactolith-app">
<h1>Page 1</h1>
<ui-toggle json-pressed="false">Toggle</ui-toggle>
<a href="page2.html">Go to page 2</a>
</div><!-- page2.html -->
<div id="reactolith-app">
<h1>Page 2</h1>
<ui-toggle json-pressed="true">Toggle</ui-toggle>
<a href="page1.html">Go to page 1</a>
</div>Only the <h1> text and the pressed prop are updated — everything else remains untouched ✅.
Props
If you pass props to your reactolith components like this:
<my-component enabled name="test" data-foo="baa" as="{my-other-component}" json-config='{ "foo": "baa" }'your components will get this props:
const props = {
enabled: true,
name: 'test',
foot: 'baa',
as: <MyOtherComponent />,
config: { foo: 'baa' },
}Slots
reactolith also provides a simple slot mechanism: Every child if a reactolith-component with a slot attribute will be transformed to a slot property, holding the children of the element:
<my-component>
<template slot="header"><h1>My header content</h1></template>
<div slot="footer">My footer content</div>
</my-component>your components will get this props:
function MyComponent({ header, footer } : { header : ReactNode, footer : ReactNode }) {
<article>
<header>{header}</header>
<div>My content</div>
<footer>{footer}</footer>
<aside>
<footer>{footer}</footer>
</aside>
</article>
}🔄 Scroll Restoration
reactolith automatically manages scroll position during navigation, just like a traditional multi-page website:
| Navigation | Behavior |
|---|---|
| Link click / Form submit | Scrolls to top |
| URL with #hash | Scrolls to the hash element |
| Browser Back / Forward | Restores previous scroll position |
Scroll positions are stored in sessionStorage, so they survive page refreshes within the same tab.
Preserve Scroll Position
Sometimes you don't want to scroll to the top after navigation (e.g., in-page filters, pagination). Add data-scroll="preserve" to the link or form:
<!-- Link preserves scroll position -->
<a href="/products?page=2" data-scroll="preserve">Next Page</a>
<!-- Form preserves scroll position -->
<form action="/search" method="GET" data-scroll="preserve">
<input type="text" name="q" />
<button type="submit">Search</button>
</form>Programmatic Navigation
// Default: scrolls to top
router.navigate("/page");
// Preserve current scroll position
router.navigate("/page", { scroll: "preserve" });Custom Scroll Container
By default, reactolith auto-detects the scroll container by walking up the DOM from the root element and finding the nearest ancestor with overflow-y: auto|scroll. If none is found, window is used.
You can override this with an explicit selector:
<div id="reactolith-app" data-scroll-container="#main-content">
...
</div>📡 Real-time Updates with Mercure
reactolith supports Server-Sent Events (SSE) via Mercure for real-time updates from your backend. When the server publishes an update, the HTML is automatically rendered — just like with router navigation.
Mercure automatically subscribes to the current URL pathname as the topic and re-subscribes when the route changes.
Auto-Configuration (Recommended)
The easiest way to configure Mercure is to add the data-mercure-hub-url attribute to your root element:
<div id="reactolith-app" data-mercure-hub-url="https://example.com/.well-known/mercure">
<!-- Your content -->
</div>
<!-- With credentials (cookies): -->
<div id="reactolith-app"
data-mercure-hub-url="https://example.com/.well-known/mercure"
data-mercure-with-credentials>
<!-- Your content -->
</div>import { App, Mercure } from "reactolith";
const app = new App(component);
// mercureConfig is automatically set from data-mercure-hub-url attribute
const mercure = new Mercure(app);
mercure.subscribe(app.mercureConfig!);
// optional listen to events
mercure.on("sse:connected", (url) => {
console.log("Connected to Mercure hub");
});Manual Configuration
Alternatively, you can configure Mercure programmatically:
import { App, Mercure } from "reactolith";
const app = new App(component);
const mercure = new Mercure(app);
// Subscribe to Mercure hub (uses current pathname as topic)
mercure.subscribe({
hubUrl: "https://example.com/.well-known/mercure",
withCredentials: true, // Include cookies for authentication
});When the user navigates to a different route, Mercure automatically reconnects with the new pathname as the topic.
Auto-Refetch on Empty Messages
When Mercure receives an empty message (or whitespace-only), it automatically refetches the current route. This makes it easy to invalidate the current page from the backend without having to render and send the full HTML:
Backend (simple invalidation):
// Just notify that the page should refresh - no HTML needed
$hub->publish(new Update('/dashboard', ''));Instead of:
// Old way: render and send full HTML
$html = $twig->render('dashboard.html.twig', $data);
$hub->publish(new Update('/dashboard', $html));This triggers a GET request to the current URL and renders the response.
Mercure Events
| Event | Arguments | Description |
|-------|-----------|-------------|
| sse:connected | url | Connection established |
| sse:disconnected | url | Connection closed |
| sse:message | event, html | Message received |
| render:success | event, html | HTML rendered successfully |
| render:failed | event, html | Render failed (no root element) |
| refetch:started | event | Auto-refetch triggered (empty message) |
| refetch:success | event, html | Auto-refetch completed successfully |
| refetch:failed | event, error | Auto-refetch failed |
| sse:error | error | Connection error |
Live Data with useMercureTopic
For simple live values (like notification counts, user status), use the useMercureTopic hook to subscribe to Mercure topics that send JSON data:
import { useMercureTopic } from 'reactolith';
// Simple types - inferred from initial value
function NotificationBadge() {
const count = useMercureTopic('/notifications/count', 0);
if (count === 0) return null;
return <span className="badge">{count}</span>;
}
// Explicit type parameter
function UserStatus({ userId }: { userId: number }) {
const status = useMercureTopic<'online' | 'offline' | 'away'>(
`/user/${userId}/status`,
'offline'
);
return <span className={status}>{status}</span>;
}
// Complex types with interfaces
interface DashboardStats {
visitors: number;
sales: number;
conversion: number;
}
function Dashboard() {
const stats = useMercureTopic<DashboardStats>('/dashboard/stats', {
visitors: 0,
sales: 0,
conversion: 0,
});
return (
<div>
<span>Visitors: {stats.visitors}</span>
<span>Sales: {stats.sales}</span>
<span>Conversion: {stats.conversion}%</span>
</div>
);
}Backend:
// Push JSON data to topic
$hub->publish(new Update(
'/notifications/count',
json_encode(42)
));Note: When using useMercureTopic, make sure app.mercureConfig is set. You can either:
- Use the auto-configuration by adding
data-mercure-hub-urlto your root element (recommended), or - Set it manually:
const app = new App(component);
app.mercureConfig = {
hubUrl: "/.well-known/mercure",
withCredentials: true,
};Custom Live Regions (Partial Updates)
For partial updates (e.g., updating a sidebar across all pages), you can create your own live region component. The mercureConfig is accessible via useApp():
Setup:
import { App, Mercure, MercureLive } from 'reactolith';
import loadable from '@loadable/component';
const component = loadable(
async ({ is }: { is: string }) => {
// The mapping is up to you, reactolith only provides the MercureLive Component (don't lazy load it!)
if (is === 'mercure-live') {
return MercureLive;
}
// Your default implementaiton
return import(`./components/${is}.tsx`);
},
{
cacheKey: ({ is }) => is,
resolveComponent: (mod, { is }) => {
if (is === 'mercure-live') {
return mod;
}
return mod.default || mod[is];
}
}
);
const app = new App(component);
const mercure = new Mercure(app);
// Store config for components to access
app.mercureConfig = {
hubUrl: "/.well-known/mercure",
withCredentials: true,
};
mercure.subscribe(app.mercureConfig);Beispiel: Live Sidebar
// components/sidebar.tsx
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside className="sidebar">
{children}
</aside>
);
}HTML Usage:
<div id="reactolith-app">
<nav>...</nav>
<!-- Diese Region wird live aktualisiert -->
<mercure-live topic="/sidebar">
<sidebar>
<ul>
<li>Initial menu item 1</li>
<li>Initial menu item 2</li>
</ul>
</sidebar>
</mercure-live>
<main>...</main>
</div>Backend:
// Render die Sidebar neu
$html = $twig->render('_sidebar.html.twig', [
'menuItems' => $updatedMenuItems
]);
// Push zu allen Clients
$hub->publish(new Update('/sidebar', $html));Template (_sidebar.html.twig):
<sidebar>
<ul>
{% for item in menuItems %}
<li>{{ item.label }}</li>
{% endfor %}
</ul>
</sidebar>🧩 IDE Autocomplete (Web-Types)
reactolith includes a CLI tool to generate web-types for your custom components. This enables autocomplete and validation in IDEs like WebStorm, PhpStorm, and VS Code (with appropriate plugins).
Generate web-types.json
npx generate-web-types -c src/components/ui -o web-types.json -n my-appThe generator recursively scans the components directory, so both flat and nested structures are supported:
src/components/ui/
button.tsx # flat
card.tsx # flat
accordion/
accordion.tsx # nested
accordion-item.tsx # nestedAll components are discovered automatically — no extra configuration needed.
Options:
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| --components | -c | Components directory (scanned recursively) | components/ui |
| --tsconfig | -t | TypeScript config file | tsconfig.app.json (or tsconfig.json) |
| --out | -o | Output file | web-types.json |
| --name | -n | Library name | reactolith-components |
| --version | -v | Library version | 1.0.0 |
| --prefix | -p | Element name prefix | "" |
| --help | -h | Show help | |
Examples:
# Minimal — uses defaults for everything else
npx generate-web-types -c src/components/ui
# With prefix — all elements get a "ui-" prefix (e.g. <ui-button>, <ui-card>)
npx generate-web-types -c src/components -p ui- -o web-types.json
# Full example with all options
npx generate-web-types \
-c src/components/ui \
-o web-types.json \
-n my-app \
-v 2.0.0 \
-p app- \
-t tsconfig.app.json
# Custom tsconfig (e.g. monorepo or library setup)
npx generate-web-types -c packages/ui/src -t packages/ui/tsconfig.json -o packages/ui/web-types.jsonConfigure your project
Add the generated file to your package.json:
{
"name": "my-app",
"web-types": "./web-types.json"
}Result
After restarting your IDE, you'll get:
- ✅ Autocomplete for custom element names (e.g.,
<ui-button>) - ✅ Prop suggestions with types and descriptions
- ✅ Slot hints for components with children/slots
- ✅ Validation for required props and valid values
Tip: Add npx generate-web-types ... to your build script to keep web-types in sync:
{
"scripts": {
"build": "vite build && npx generate-web-types -c src/components/ui -o web-types.json"
}
}🤝 Contributing
Contributions are welcome! Feel free to open an issue or submit a PR.
🛠 Development Build
If you’re contributing to this library:
npm install
npm run build