Inertia.js on Symfony: A Pragmatic Middle Path Between Twig and a Fully Decoupled SPA
There is a common inflection point in Symfony projects: the admin panel has grown past what Twig can comfortably render, the frontend developers want component-based React, but nobody wants to maintain a fully decoupled SPA with its own routing, its own auth state, its own API contract, and its own deployment pipeline. The usual response is either to suffer with increasingly complex Twig, or to commit to a full architectural rewrite that will take six months and break everything.
Inertia.js Symfony integration offers a third way. It lets you write React page components while keeping Symfony in charge of routing, authentication, and server-side data. There is no REST API to design, no JSON serialisation layer to maintain, no client-side router to configure. Symfony controllers return page components instead of HTML, and Inertia handles the rest.
This post covers a real Symfony 7 + Inertia + React integration: how the controller-to-component mapping works, how CSRF and authentication flow without an API layer, how server-side navigation stays in PHP, how SSR works with a Node.js sidecar, and how to migrate a Twig admin panel page by page without a big-bang rewrite.
What Inertia.js Actually Does
Before the implementation details, it helps to be precise about what Inertia is and is not. Inertia is not a frontend framework, not a build tool, and not a state management library. It is an adapter protocol — a thin layer that sits between a server-side framework (Symfony, Laravel, Rails) and a client-side component framework (React, Vue, Svelte).
When a user visits a page, Inertia returns a full HTML response the first time — a standard Symfony response with a single root element containing a data-page attribute that encodes the component name and its props as JSON. On subsequent navigations, the browser sends an XHR request with an X-Inertia header, and Symfony returns a JSON response with the new component name and new props. Inertia's client-side adapter intercepts the response, swaps the component, and updates the browser history. From the user's perspective it behaves like a SPA. From the developer's perspective the routing logic never left PHP.
The key implication: your Symfony routes still define the URL structure. Your Symfony controllers still handle authentication, authorisation, and data loading. Your Doctrine queries and your business logic are untouched. The only thing that changes is what the controller returns — instead of a Twig response, it returns an Inertia response that names a React component and passes it props.
Installing the Symfony Inertia Adapter
The community-maintained rompetomp/inertia-bundle is the standard Symfony adapter. Install it alongside the Vite bundle and the Inertia React adapter:
composer require rompetomp/inertia-bundle
npm install @inertiajs/react react react-dom
npm install --save-dev @vitejs/plugin-react
Configure your vite.config.ts to use the React plugin and point the Symfony Vite bundle at your entry point:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import symfonyPlugin from 'vite-plugin-symfony';
export default defineConfig({
plugins: [react(), symfonyPlugin()],
build: {
rollupOptions: {
input: { app: './assets/app.tsx' },
},
},
});
In assets/app.tsx, initialise the Inertia React adapter and point it at your page components:
import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./pages/**/*.tsx', { eager: true });
return pages[`./pages/${name}.tsx`];
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
});
Your base Twig layout needs a single line change — the inertia() helper replaces the main content block:
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
{{ vite_entry_link_tags('app') }}
</head>
<body>
{{ inertia() }}
{{ vite_entry_script_tags('app') }}
</body>
</html>
Controller-to-Component Mapping
The Inertia response object is what replaces $this->render() in your controllers. The first argument is the component path relative to your pages/ directory; the second is the props array, which becomes the component's typed props.
// src/Controller/ProjectController.php
use Rompetomp\InertiaBundle\Service\InertiaInterface;
class ProjectController extends AbstractController
{
public function __construct(private InertiaInterface $inertia) {}
#[Route('/projects', name: 'project_index')]
public function index(ProjectRepository $repo): Response
{
return $this->inertia->render('Projects/Index', [
'projects' => $repo->findActiveForUser($this->getUser()),
'canCreate' => $this->isGranted('PROJECT_CREATE'),
]);
}
}
On the React side, the component receives those props directly:
// assets/pages/Projects/Index.tsx
interface Project {
id: number;
name: string;
status: string;
}
interface Props {
projects: Project[];
canCreate: boolean;
}
export default function ProjectIndex({ projects, canCreate }: Props) {
return (
<div>
{canCreate && <CreateProjectButton />}
{projects.map((p) => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
Notice what is missing: there is no fetch() call, no loading state, no API endpoint to maintain. The data arrives from Symfony as props. The component renders it. This is the fundamental difference between Inertia and a decoupled SPA — the data contract lives in your PHP controller, not in an HTTP API schema.
For services that need custom serialisation or access control enforcement at the data layer, wrapping the Doctrine entity in a dedicated DTO before passing it to Inertia keeps the component interface clean and prevents accidental exposure of fields your frontend never needs.
CSRF and Authentication Without an API Layer
One of the most common concerns when teams consider a decoupled SPA is auth complexity. With a conventional SPA you need to implement token-based auth (JWT or OAuth), maintain token refresh logic, handle CORS, and keep the token out of XSS reach. Inertia sidesteps all of this because it is not a decoupled SPA — it runs inside a standard Symfony session.
CSRF protection works automatically. Inertia's client-side adapter reads the CSRF token from the cookie Symfony sets and sends it as an X-XSRF-TOKEN header on every mutating request. Your Symfony CSRF middleware verifies it as normal. No special configuration required.
Authentication is handled through Symfony's security system. You configure firewalls, access control rules, and voters exactly as you would for a Twig application. If a user is not authenticated, Symfony redirects them to the login page — Inertia follows the redirect and renders the login component. If a user lacks permission for a resource, your controller throws AccessDeniedException and Symfony's exception subscriber handles it. The frontend never needs to know about authentication logic; it simply receives the data the controller decided to share.
For sharing common data across all pages — the authenticated user's name, role, notification count, global flash messages — Inertia provides a shared data mechanism. Configure it in a service or event listener:
// src/EventListener/InertiaShareListener.php
class InertiaShareListener
{
public function onKernelRequest(RequestEvent $event): void
{
$user = $this->security->getUser();
$this->inertia->share('auth', [
'user' => $user ? ['name' => $user->getName(), 'role' => $user->getRole()] : null,
]);
$this->inertia->share('flash', $this->session->getFlashBag()->all());
}
}
Every page component receives auth and flash without the controller having to pass them explicitly. This replaces the Twig global variables pattern cleanly.
If you need Wolf-Tech's help wiring up Symfony security for an existing application or migrating from a JWT-based setup, see our consulting services — this is work we do regularly.
Server-Driven Navigation Keeps Routing Logic in PHP
In a decoupled SPA, the client-side router owns the URL structure. You define routes in React Router or Next.js, the browser navigates them without touching the server, and the backend provides data via API. This creates a maintenance problem: your URLs are defined in two places — the PHP backend that secures and validates them, and the JavaScript router that renders them. When a route changes, both need updating.
With Inertia, Symfony owns the URL structure entirely. Every navigation — link clicks, form submissions, programmatic redirects — sends a request to Symfony. Symfony's router resolves it, the controller runs, and Inertia returns either a new component (for navigation) or a redirect (for form submissions). The frontend has no knowledge of what URL maps to what component; that mapping lives in Symfony's routing configuration.
In React components, use Inertia's Link component for navigation and router.visit() or useForm() for programmatic actions:
import { Link, useForm } from '@inertiajs/react';
function ProjectActions({ projectId }: { projectId: number }) {
const { post, processing } = useForm({});
return (
<>
<Link href={`/projects/${projectId}/edit`}>Edit</Link>
<button
onClick={() => post(`/projects/${projectId}/archive`)}
disabled={processing}
>
Archive
</button>
</>
);
}
The /projects/${projectId}/edit URL is defined once, in config/routes.yaml or as a PHP attribute. Symfony validates access on every visit. There is no client-side route guard to maintain in parallel.
SSR Setup with a Node.js Sidecar
For admin panels that are largely behind authentication, server-side rendering is optional. The initial page load is server-rendered by Symfony (Inertia always returns a full HTML document on the first request), and subsequent navigations are client-side. Search engines typically do not crawl behind authentication, so SEO is not a concern.
For public-facing Inertia pages, SSR matters. Inertia's SSR support works by running a Node.js process alongside your Symfony application that receives a JSON representation of the component and its props, renders it to an HTML string using ReactDOMServer, and returns that string to Symfony to embed in the initial response.
Configure the SSR server in your inertia.yaml:
rompetomp_inertia:
ssr:
enabled: true
url: 'http://127.0.0.1:13714'
Build the SSR bundle separately in your Vite config:
// vite.ssr.config.ts
export default defineConfig({
build: {
ssr: true,
rollupOptions: {
input: 'assets/ssr.tsx',
},
},
});
Run the Node sidecar as a supervised process (systemd unit, Docker sidecar container, or Supervisor) alongside your PHP-FPM process. If the SSR server is unavailable, Inertia falls back to client-side rendering gracefully — the page still loads, just without server-rendered HTML in the initial response.
For most Symfony admin panels, the SSR setup is not worth the operational complexity. For marketing pages, public dashboards, or SEO-critical content that you are migrating to Inertia, it is worth the investment.
Migrating a Twig Admin Panel Page by Page
The migration path from Twig to Inertia does not require a big-bang rewrite. Twig and Inertia coexist in the same application — some controllers return Twig responses, others return Inertia responses. You migrate one page at a time.
A practical migration sequence: start with the pages that are the most painful to maintain in Twig — typically pages with heavy JavaScript already embedded via <script> blocks, or pages where your frontend developers have been asking for React component isolation. These pages have the most to gain and the most context already available.
For each page migration, the controller change is minimal. Replace $this->render('admin/projects/index.html.twig', [...]) with $this->inertia->render('Admin/Projects/Index', [...]). Convert the Twig template's data-passing logic into a typed props array. Create the React component. Run both in parallel during a transition period if needed — Symfony's routing lets you serve the old Twig URL and a new /v2/ Inertia URL simultaneously for testing.
The data layer does not change at all during migration. Your Doctrine repositories, your service classes, your security voters — none of this is touched. You are only changing the presentation layer, which is exactly what a view migration should be.
For forms, replace Symfony Form component renders with Inertia's useForm() hook. The validation errors returned by Symfony on a failed form submission are automatically available as errors in the Inertia response, without any custom error handling on the PHP side:
if ($form->isSubmitted() && !$form->isValid()) {
return back()->withErrors(
collect($form->getErrors(true))->mapWithKeys(fn($e) => [
$e->getOrigin()->getName() => $e->getMessage()
])->all()
);
}
On the React side, errors.fieldName contains the server-side validation message, ready to display inline.
When Inertia Is the Right Choice — and When It Is Not
Inertia's sweet spot is server-rendered applications where the frontend complexity has grown past Twig's comfort zone, but where a separate frontend deployment, a designed API contract, and client-side auth would add more complexity than they solve. Admin panels, internal tools, SaaS dashboards, and B2B application interfaces typically fit this profile well.
Inertia is not the right choice for applications that need a public API anyway — if mobile apps, third-party integrations, or public developer APIs are on the roadmap, build the API first and let the React frontend consume it like any other client. Inertia's tightly-coupled server-to-component binding is a liability when the data contract needs to be consumed by multiple clients.
It is also not a good fit for heavy client-side state — applications where most of the interesting state lives in the browser (collaborative editing tools, real-time dashboards with WebSocket data, complex client-side workflows) benefit from a frontend architecture that treats the server as a data source, not a page orchestrator.
For the Symfony teams we work with at Wolf-Tech, Inertia has been the right answer roughly when the question is "how do we make this admin panel maintainable without rebuilding the entire backend." If that question sounds familiar, reach out at hello@wolf-tech.io or visit wolf-tech.io to talk through your specific situation.
The middle path is often the most pragmatic one.

