React + Vite Integration
Rhapsody's React integration gives you the best of both worlds: the power and interactivity of React components within your traditional Twig templates. Whether you want to embed a single interactive component ("React Islands") or build a full single-page application, the integration seamlessly bridges your PHP backend with a modern React + Vite frontend.
- ✅ React Islands – embed components directly in Twig templates
- ✅ Full SPA Support – render entire pages with React
- ✅ Vite HMR – instant hot module reloading during development
- ✅ XSS-Safe Props – JSON props embedded in safe script blocks
- ✅ CSRF Protection – automatic token injection for secure POST requests
- ✅ Production Manifest – fingerprinted assets with import preloading
Installation
The React integration is available as a core feature. To install it into your application, run:
php rhapsody react:install
This command scaffolds:
vite.config.js– Vite build configurationpackage.json– npm manifest with React + Vite dependenciesresources/js/app.jsx– JavaScript entry point (the Rhapsody bridge)resources/js/components/Welcome.jsx– example component- Updates
.envand.env.examplewith Vite configuration variables
- 1. Run
npm installto install JavaScript dependencies - 2. Development: set
VITE_DEV_SERVER=truein.envand runnpm run dev - 3. Production: run
npm run build
Environment Variables
The React integration uses two environment variables that control how assets are loaded:
VITE_DEV_SERVER
When set to true, assets are served from the Vite dev server running on localhost:5173.
This enables Hot Module Replacement (HMR) for instant feedback during development.
VITE_DEV_SERVER=true
VITE_PORT
The port Vite runs on. Defaults to 5173. Change this if you're running multiple Vite instances.
VITE_PORT=5173
When VITE_DEV_SERVER=false or omitted, the integration automatically uses the production build
from {ROOT_DIRECTORY}/public/build/ with fingerprinted asset URLs from the Vite manifest.
Using React in Controllers
Full React Pages
For single-page applications or pages entirely powered by React, use the react() method in your controller (in place of the normal view() call):
// In a controller
class DashboardController extends BaseController
{
public function index(): Response
{
return $this->react('Dashboard', [
'user' => [
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
],
'stats' => [
'posts' => 42,
'followers' => 128,
],
], [
'title' => 'Dashboard',
'description' => 'User dashboard with live statistics',
]);
}
}
This renders a full HTML page with:
- Your component mounted at
#rhapsody-root - Props passed safely via a
<script type="application/json">block - CSRF token available as a
<meta>tag - Vite assets automatically injected (dev server or production manifest)
The component is resolved from resources/js/components/{ComponentName}.jsx.
Nested paths are supported: $this->react('Users/ProfileCard', $props).
React Islands in Twig
For embedding React components within traditional Twig templates, use the react_component() function:
{# In your Twig template #}
{% extends "@core/layouts/main.twig" %}
{% block content %}
<h1>Welcome to your dashboard</h1>
<p>Here's your live statistics:</p>
{{ react_component('UserStats', {
userId: auth.user.id,
period: 'monthly',
stats: stats
}) }}
<p>And your recent activity:</p>
{{ react_component('ActivityFeed', { limit: 10 }) }}
{% endblock %}
Each component gets its own mount point with isolated props. The JavaScript bridge
automatically finds all .rhapsody-island elements and mounts the specified components.
JavaScript Bridge
The resources/js/app.jsx entry point contains the Rhapsody bridge that
automatically mounts both full-page components and islands:
// resources/js/app.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx'; // Full-page application
// Import your components
import UserStats from './components/UserStats.jsx';
import ActivityFeed from './components/ActivityFeed.jsx';
// --- 1. Full-Page React App ---
const root = document.getElementById('rhapsody-root');
if (root) {
const componentName = root.getAttribute('data-component');
const props = JSON.parse(
document.getElementById('rhapsody-props').textContent
);
// Dynamic import based on component name
// This keeps the bundle size small
import(`./components/${componentName}.jsx`)
.then(module => {
const Component = module.default;
const reactRoot = createRoot(root);
reactRoot.render(<Component {...props} />);
});
}
// --- 2. React Islands ---
document.querySelectorAll('.rhapsody-island').forEach(element => {
const componentName = element.getAttribute('data-component');
const propsElement = element.querySelector('.rhapsody-island-props');
const props = JSON.parse(propsElement.textContent);
import(`./components/${componentName}.jsx`)
.then(module => {
const Component = module.default;
const reactRoot = createRoot(element);
reactRoot.render(<Component {...props} />);
});
});
Creating Components
Using the CLI Generator
Generate new React components quickly with the make:react command:
# Simple component
php rhapsody make:react Dashboard
# Nested component
php rhapsody make:react Users/ProfileCard
# Any level of nesting
php rhapsody make:react Admin/Settings/UserPreferences
Components are created in resources/js/components/ with a preconfigured template.
The component name must be PascalCase (e.g., UserProfile, ActivityFeed).
Component Template
The generated component stub looks like this:
// resources/js/components/Dashboard.jsx
import React from 'react';
/**
* Dashboard component
*
* Props:
* @param {Object} user - Current user data
* @param {Array} stats - User statistics
*/
export default function Dashboard({ user, stats }) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Welcome, {user.name}!</h1>
<p>You have {stats?.length || 0} items.</p>
</div>
);
}
CSRF Token Handling
The integration automatically injects the CSRF token as a <meta> tag:
<meta name="csrf-token" content="your_csrf_token_here">
In React, you can read it for secure POST requests:
// In your React component
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const response = await fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify(data),
});
You can also access the CSRF token directly in Twig using the csrf_token() function:
<meta name="csrf-token" content="{{ csrf_token() }}">
Vite Asset Management
Development Mode
When VITE_DEV_SERVER=true, assets are served from the Vite dev server.
This provides:
- Hot Module Replacement (HMR) – instant updates without page reload
- Fast build times – only what's needed is compiled
- Source maps for debugging
Run the dev server:
npm run dev
Production Mode
When VITE_DEV_SERVER=false, assets are built and served from the production manifest.
This provides:
- Minified, optimized assets
- Fingerprinted filenames for cache busting
- Automatic preloading of imported modules
- CSS extraction and optimization
Build for production:
npm run build
Manual Asset Inclusion
In any Twig template, you can manually include Vite assets with the vite_assets() function:
{% block head %}
{{ vite_assets('resources/js/app.jsx') }}
{% endblock %}
This emits the correct tags:
- Development:
<script type="module" src="http://localhost:5173/@vite/client">+ your entry script - Production:
<link rel="stylesheet">,<link rel="modulepreload">, and<script type="module">tags
Architecture Overview
Data Flow
┌─────────────────────────────────────────────────────────────────────┐
│ PHP Backend (Controller) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ $this->react('Dashboard', ['user' => $user]); │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ $this->twig->addExtension(new ReactIslandExtension()); │ │
│ │ // Props are JSON-encoded with JSON_HEX_TAG for XSS safety │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ HTML Output │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ <div id="rhapsody-root" data-component="Dashboard"></div> │ │
│ │ <script type="application/json" id="rhapsody-props"> │ │
│ │ {"user":{"id":1,"name":"John"}} │ │
│ │ </script> │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ JavaScript Bridge (app.jsx) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ const props = JSON.parse( │ │
│ │ document.getElementById('rhapsody-props').textContent │ │
│ │ ); │ │
│ │ import(`./components/${componentName}.jsx`) │ │
│ │ .then(module => { │ │
│ │ createRoot(root).render(<Component {...props} />); │ │
│ │ }); │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ React Component (resources/js/components/Dashboard.jsx) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ export default function Dashboard({ user }) { │ │
│ │ return <h1>Welcome, {user.name}!</h1>; │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Security Considerations
XSS Protection
Props are serialised with JSON_HEX_TAG, which escapes </script> sequences:
$propsJson = json_encode($props, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE);
This prevents </script> injection inside the JSON block, making it safe to embed
arbitrary user data.
CSRF Protection
The integration automatically injects the CSRF token as a <meta> tag.
Use it in your React components for secure POST requests as shown above.
Content Security Policy (CSP)
If you use a strict CSP, the development mode (VITE_DEV_SERVER=true) may require
connect-src and script-src to allow the Vite dev server.
Add this to your CSP policy:
connect-src http://localhost:5173;
script-src http://localhost:5173;
Common Workflows
1. Build a new React page
- Generate the component:
php rhapsody make:react Dashboard - Edit
resources/js/components/Dashboard.jsx - In a controller:
return $this->react('Dashboard', $props); - Start Vite:
npm run dev
2. Embed an interactive component in a Twig template
- Generate the component:
php rhapsody make:react UserSearch - In your Twig template:
{{ react_component('UserSearch', { endpoint: '/api/users' }) }} - Start Vite:
npm run dev
3. Switch to production
- Set
VITE_DEV_SERVER=falsein.env - Build assets:
npm run build - Deploy as usual
Troubleshooting
Component not found
[Rhapsody/React] Vite manifest entry 'resources/js/app.jsx' not found.
Solution: Run npm run build or set VITE_DEV_SERVER=true.
CSRF token missing
document.querySelector('meta[name="csrf-token"]') is null
Solution: Ensure the session is started before rendering the React page.
The Session::start() must be called before $this->react().
HMR not working
- Check that
VITE_DEV_SERVER=trueis set in.env - Ensure
npm run devis running - Check that
VITE_PORTmatches the port Vite is running on - Clear your browser cache
Props not updating
Props are static values set at render time. If you need live data, fetch it from an API endpoint inside your React component.
Check the FAQ or open an issue on GitHub.