limenius/react-renderer
Limenius ReactRenderer lets PHP applications render React components server-side by delegating to a Node.js process, so you get fast, SEO-friendly markup with props passed from PHP. Includes Symfony bundle integration and Twig helpers for easy embedding.
limenius/react-renderer is Symfony-first but framework-agnostic at its core — it is a handful of plain PHP classes in Limenius\ReactRenderer\* plus a Node side that executes a webpack-built server bundle. In Laravel you will not get auto-discovery, so plan on ~30 lines of wiring.
1. Know the four things that matter
| Namespace | What it is |
|---|---|
Renderer\ReactRendererInterface |
render(string $component, array $props, array $options): string — returns HTML |
Renderer\PhpExecJsReactRenderer |
Spawns Node per render (via nacmartin/phpexecjs). Zero infra, slower. |
Renderer\ExternalNodeReactRenderer |
POSTs to a long-running Node HTTP server. Fast, needs supervision. |
Context\ContextProviderInterface |
getContext(): array — data merged into props.context on the server only |
Skim README.md for the server-bundle webpack config and the Express example, then read PhpExecJsReactRenderer::render() — that's the whole library.
Pin ^5.0.4. Earlier 5.x releases carry a PHP 8 deprecation (Required parameter $trace follows optional parameter $registeredStores in PhpExecJsReactRenderer.php). It is only a deprecation, not a fatal — but it will spam your logs on every render and it tells you something important about the constructor: there is a $registeredStores parameter sitting in front of $trace. Pass arguments by name, not by position, and this whole class of problem disappears.
2. Build the server bundle (the real first hurdle)
Your resources/js/server.jsx must be a CommonJS build with target: 'node' and libraryTarget: 'commonjs2', and register components via the ReactOnRails API:
// resources/js/server.jsx
import ReactOnRails from 'react-on-rails';
import Dashboard from './components/Dashboard';
ReactOnRails.register({ Dashboard });
With Vite, add a second config (build.ssr: 'resources/js/server.jsx', build.outDir: 'public/js', rollupOptions.output.format: 'cjs'); with Mix use .target('node') and a separate webpack.server.js.
3. Wire it in a service provider
// app/Providers/ReactRendererServiceProvider.php
use Limenius\ReactRenderer\Renderer\ReactRendererInterface;
use Limenius\ReactRenderer\Renderer\PhpExecJsReactRenderer;
use Limenius\ReactRenderer\Context\ContextProviderInterface;
class RequestContext implements ContextProviderInterface
{
public function __construct(private array $ctx) {}
public function getContext(): array { return $this->ctx; }
}
$this->app->singleton(ReactRendererInterface::class, fn () => new PhpExecJsReactRenderer(
bundlePath: base_path('public/js/server-bundle.js'), // server bundle
failLoud: app()->isLocal(), // $failLoud
contextProvider: new RequestContext([ // context provider
'locale' => app()->getLocale(),
'path' => request()->path(),
]),
mode: 'both', // server_side | client_side | both
trace: false // adds data-* debug attributes
));
Named arguments are not style preference here — the constructor has gained parameters over time ($registeredStores before $trace), so a positional ..., 'both', false can silently bind false to a store registry instead of $trace depending on the exact version. If your Laravel/PHP floor predates PHP 8.0, fall back to positional args but verify the signature of the installed class with ReflectionMethod or by reading vendor/limenius/react-renderer/src/.../PhpExecJsReactRenderer.php directly.
4. First use case: SEO landing page in Blade
{{-- resources/views/welcome.blade.php --}}
{!! app(\Limenius\ReactRenderer\Renderer\ReactRendererInterface::class)
->render('Dashboard', ['user' => $user->only('name', 'plan')]) !!}
render() already emits the React-On-Rails wrapper <div data-react-class data-react-props> with the server HTML inside it. Do not wrap it in your own container — client hydration finds it by those attributes. Then in resources/js/app.jsx:
import { createRoot } from 'react-dom/client';
import ReactOnRails from 'react-on-rails';
import Dashboard from './components/Dashboard';
ReactOnRails.register({ Dashboard });
document.querySelectorAll('[data-react-class]').forEach((el) => {
const Cmp = /* resolve from registry */;
const props = JSON.parse(el.dataset.reactProps);
createRoot(el).hydrate(<Cmp {...props} />);
});
Renderer choice drives your whole architecture. PhpExecJsReactRenderer boots Node and re-evaluates the bundle on every render (tens of milliseconds, plus memory). Fine for a few marketing pages, unacceptable for a logged-in dashboard. Move to ExternalNodeReactRenderer early: run the Express server under supervisor/systemd on 127.0.0.1:3000, add a /health route, and restart it on deploy after the new bundle is on disk.
Context is the server/client contract. Server components receive props.context; the client does not get it automatically. Either serialize the same context into the page and merge it in your entrypoint, or write components that degrade gracefully when props.context is undefined:
<script type="application/json" id="react-context">@json($context)</script>
const context = JSON.parse(document.getElementById('react-context').textContent);
// pass into every component as props.context before hydrate
rendering option as a per-component switch. server_side (HTML only, no JS needed), client_side (empty wrapper, hydrate later), both (server HTML + hydration). Use client_side for anything behind auth or with live data; both for marketing/detail pages.
Blade component wrapper keeps views clean:
{{-- resources/views/components/react.blade.php --}}
<div {{ $attributes }}>
{!! app(\Limenius\ReactRenderer\Renderer\ReactRendererInterface::class)
->render($component, $props, ['rendering' => $rendering ?? 'both']) !!}
</div>
Cache aggressively. Server rendering is pure: same component + same props = same HTML. Wrap the renderer in a decorator implementing ReactRendererInterface that hashes the props and checks Redis:
class CachedRenderer implements ReactRendererInterface
{
public function render($component, $props = [], $options = [])
{
$key = 'react:'.md5($component.serialize($props).serialize($options));
return Cache::remember($key, 3600, fn () => $this->inner->render($component, $props, $options));
}
}
This is the single highest-leverage optimization and the reason to keep ReactRendererInterface as your injected type everywhere.
Keep props JSON-serializable. No Eloquent models, Carbon objects with timezones, or closures — json_encode will silently drop them. Map to plain arrays in a presenter/view-model. Anything the component needs to fetch must be fetched in PHP first; there is no useEffect pass on the server.
$registeredStores is your Redux seam. The optional parameter ahead of $trace is the hook for passing pre-populated store state into the server bundle. If you use Redux (or a Redux-shaped store), populate it from PHP alongside your props so the server render matches the client's initial state — otherwise you get the classic "server rendered one thing, client rendered another" flash. If you don't use Redux, ignore the parameter entirely.
Progressive enhancement / non-React page sprinkles. This package shines when React is a widget inside a Blade app (a price configurator, a chart, a filter panel) rather than a full SPA. If you're building a whole SPA in Laravel, Inertia SSR is a better fit and this package buys you nothing.
$registeredStores (optional) preceding $trace (required) in PhpExecJsReactRenderer.php. Use named arguments; if you must stay positional, read the installed class first. Add a smoke test that instantiates the renderer so a future release can't quietly shift the meaning of your false.failLoud: false silently degrades. When the server bundle throws, you get the wrapper div with no inner HTML and no exception — a blank page that looks like a CSS bug. Keep it true in local/staging, false in production, and log renderer failures in a decorator.Date.now()/Math.random()/new Date() at render time. Compare the data-react-props JSON with what your entrypoint computes.window-free at import time. Any module touching document at top level (analytics, jQuery plugins, localStorage) breaks the whole bundle. Guard with typeof window !== 'undefined' or lazy-import inside useEffect.styled-components/Emotion generate <style> tags inside the returned HTML; they work but you must extract and dedupe them server-side, which this package does not do for you. Plain CSS, CSS Modules, or utility classes avoid the problem entirely.trace: true adds data-* attributes to the wrapper — the fastest way to see whether the server or the client produced a given node. Turn it off in production, and remember it's the argument people most often mis-bind when passing positionally.phpexecjs and the Node running your external server must both be able to require() the bundle. Pin Node in .nvmrc, in the Docker image, and in your deploy pipeline; a bundle built with modern syntax will fail cryptically on an older runtime.E_ALL and tail storage/logs/laravel.log. The PHP 8 "required parameter follows optional" family of warnings is the first symptom of a signature you're calling wrong — catching it at install time beats discovering it via empty divs in production.data-react-props attribute-escaped, which is fine, but never put user-authored HTML into a prop that a component feeds to dangerouslySetInnerHTML.react-on-rails API. The JS side still predates React 18's renderToPipeableStream. Stay on renderToString semantics and call hydrateRoot yourself — don't expect streaming SSR, and don't assume the PHP 8 maintenance implies the Node examples were refreshed.ReactRendererInterface yourself. It's a one-method interface. If the package rots again, swap the binding for an Inertia SSR call, symfony/ux-react, or a hand-rolled Symfony HttpClient POST to your own Express route — your controllers and Blade never notice.node -e "const R = require('react-on-rails').default; require('./public/js/server-bundle.js'); \
console.log(R.serverRenderReactComponent({componentName:'Dashboard', props:{}, domNodeId:'x'}).html)"
If that throws, the problem is your bundle, not the PHP wiring.How can I help you explore Laravel packages today?