Use HTML on the server to compose your react application.
npm install react-htx> 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.
---
---
``bash`
npm install react-htx
Since react and react-dom are peer dependencies, make sure to also install them:
`bash`
npm install react react-dom
---
1. Initial Load: Symfony renders HTML with Twig, react-htx hydrates it into React components
2. Navigation: Clicking links fetches new HTML via AJAX, React reconciles the differences
3. Real-time: Mercure pushes HTML updates from server, UI updates automatically
4. State Preserved: React component state survives both navigation and real-time updates
---
Your backend returns simple HTML:
`html`
Hello world
Your frontend mounts the react-htx app:
`ts
// 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)
`
ts
// 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')
``tsx
// 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 }) => (
)
`---
🔄 Navigation Without Losing State
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).`html
``html
`Only the
text and the pressed prop are updated — everything else remains untouched ✅.---
Props
If you pass props to your htx components like this:
`html
`your components will get this props:
`tsx
const props = {
enabled: true,
name: 'test',
foot: 'baa',
as: ,
config: { foo: 'baa' },
}
`---
Slots
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:
`html
My header content
My footer content
`your components will get this props:
`tsx
function MyComponent({ header, footer } : { header : ReactNode, footer : ReactNode }) {
{header}
My content
}
`---
📡 Real-time Updates with Mercure
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.
$3
The easiest way to configure Mercure is to add the
data-mercure-hub-url attribute to your root element:`html
data-mercure-hub-url="https://example.com/.well-known/mercure"
data-mercure-with-credentials>
`typescript
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");
});
`$3
Alternatively, you can configure Mercure programmatically:
`typescript
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.
$3
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):
`php
// Just notify that the page should refresh - no HTML needed
$hub->publish(new Update('/dashboard', ''));
`Instead of:
`php
// 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.
$3
| 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 |$3
For simple live values (like notification counts, user status), use the
useMercureTopic hook to subscribe to Mercure topics that send JSON data:`tsx
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 {count};
}
// Explicit type parameter
function UserStatus({ userId }: { userId: number }) {
const status = useMercureTopic<'online' | 'offline' | 'away'>(
/user/${userId}/status,
'offline'
);
return {status};
}// Complex types with interfaces
interface DashboardStats {
visitors: number;
sales: number;
conversion: number;
}
function Dashboard() {
const stats = useMercureTopic('/dashboard/stats', {
visitors: 0,
sales: 0,
conversion: 0,
});
return (
Visitors: {stats.visitors}
Sales: {stats.sales}
Conversion: {stats.conversion}%
);
}
`Backend:
`php
// 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-url to your root element (recommended), or
- Set it manually:
`typescript
const app = new App(component);
app.mercureConfig = {
hubUrl: "/.well-known/mercure",
withCredentials: true,
};
`$3
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:
`typescript
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);
`$3
`tsx
// components/sidebar.tsx
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
);
}
`HTML Usage:
`html
- Initial menu item 1
- Initial menu item 2
...
`Backend:
`php
// 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):
`twig
{% for item in menuItems %}
- {{ item.label }}
{% endfor %}
`---
🧩 IDE Autocomplete (Web-Types)
react-htx 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).$3
`bash
npx generate-web-types -c src/components/ui -o web-types.json -n my-app
`Options:
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
|
--components | -c | Components directory | components/ui |
| --tsconfig | -t | TypeScript config file | tsconfig.app.json (or tsconfig.json) |
| --out | -o | Output file | web-types.json |
| --name | -n | Library name | htx-components |
| --version | -v | Library version | 1.0.0 |
| --prefix | -p | Element name prefix | "" |
| --help | -h | Show help | |$3
Add the generated file to your
package.json:`json
{
"name": "my-app",
"web-types": "./web-types.json"
}
`$3
After restarting your IDE, you'll get:
- ✅ Autocomplete for custom element names (e.g.,
)
- ✅ Prop suggestions with types and descriptions
- ✅ Slot hints for components with children/slots
- ✅ Validation for required props and valid valuesTip: Add
npx generate-web-types ... to your build script to keep web-types in sync:`json
{
"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:`bash
npm install
npm run build
``