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.

Key Benefits:
  • ✅ 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 configuration
  • package.json – npm manifest with React + Vite dependencies
  • resources/js/app.jsx – JavaScript entry point (the Rhapsody bridge)
  • resources/js/components/Welcome.jsx – example component
  • Updates .env and .env.example with Vite configuration variables
Next Steps:
  1. 1. Run npm install to install JavaScript dependencies
  2. 2. Development: set VITE_DEV_SERVER=true in .env and run npm run dev
  3. 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
Production Ready:

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

1. Controller → $this->react() or $this->view()
└── BaseController renders the page with embedded props
└── ViteManifest resolves asset URLs
2. Browser loads the page
└── HTML contains mount point(s) and props
└── JavaScript loads the Rhapsody bridge
3. React renders the component
└── Full-page → single root mount
└── Islands → multiple independent mounts

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

  1. Generate the component: php rhapsody make:react Dashboard
  2. Edit resources/js/components/Dashboard.jsx
  3. In a controller: return $this->react('Dashboard', $props);
  4. Start Vite: npm run dev

2. Embed an interactive component in a Twig template

  1. Generate the component: php rhapsody make:react UserSearch
  2. In your Twig template: {{ react_component('UserSearch', { endpoint: '/api/users' }) }}
  3. Start Vite: npm run dev

3. Switch to production

  1. Set VITE_DEV_SERVER=false in .env
  2. Build assets: npm run build
  3. 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=true is set in .env
  • Ensure npm run dev is running
  • Check that VITE_PORT matches 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.


Need Help?

Check the FAQ or open an issue on GitHub.