Proof of Concept – This project is still experimental and not ready for production.
react-htx 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 react-htx 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.
- 🔌 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
npm install react-htxSince react and react-dom are peer dependencies, make sure to also install them:
npm install react react-dom- Initial Load: Symfony renders HTML with Twig,
react-htxhydrates 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
Your backend returns simple HTML:
<html lang="en">
<body>
<div id="htx-app">
<h1>Hello world</h1>
<ui-button type="primary">This will be a shadcn button</ui-button>
</div>
</body>
</html>Your frontend mounts the react-htx app:
// app.ts
import loadable from '@loadable/component'
import { App } from 'react-htx'
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="htx-app" as root
new App(component)// app.ts
import loadable from '@loadable/component'
import { App } from 'react-htx'
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 "react-htx"
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>
)When navigating, react-htx 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="htx-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="htx-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 ✅.
If you pass props to your htx 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' },
}react-htx also provides a simple slot mechanism: Every child if a htx-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>
}react-htx 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.
The easiest way to configure Mercure is to add the data-mercure-hub-url attribute to your root element:
<div id="htx-app" data-mercure-hub-url="https://example.com/.well-known/mercure">
<!-- Your content -->
</div>
<!-- With credentials (cookies): -->
<div id="htx-app"
data-mercure-hub-url="https://example.com/.well-known/mercure"
data-mercure-with-credentials>
<!-- Your content -->
</div>import { App, Mercure } from "react-htx";
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");
});Alternatively, you can configure Mercure programmatically:
import { App, Mercure } from "react-htx";
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.
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.
| 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 |
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 'react-htx';
// 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,
};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 'react-htx';
import loadable from '@loadable/component';
const component = loadable(
async ({ is }: { is: string }) => {
// The mapping is up to you, react-htx 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);// components/sidebar.tsx
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside className="sidebar">
{children}
</aside>
);
}HTML Usage:
<div id="htx-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>Contributions are welcome! Feel free to open an issue or submit a PR.
If you’re contributing to this library:
npm install
npm run build