Inertia.js on Symfony: A Pragmatic Middle Path Between Twig and a Fully Decoupled SPA

#Inertia.js Symfony

Founder & Lead Developer

Expert in software development and legacy code optimization

LinkedIn

Short answer: Inertia.js lets Symfony controllers return React (or Vue, or Svelte) page components with props instead of Twig templates, so you get a single-page-app feel without building an API, a client-side router, or token auth. On Symfony it runs through a community bundle, skipthedragon/inertia-bundle, which is the maintained continuation of the original rompetomp/inertia-bundle. It fits admin panels and B2B dashboards that have outgrown Twig; it is a poor fit if you need a public API anyway.

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 Symfony 7.4 + 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

Unlike Laravel, Symfony has no official Inertia adapter. The long-time standard, rompetomp/inertia-bundle, is marked as abandoned on Packagist and only supports Symfony up to 6.x. Its code lives on as skipthedragon/inertia-bundle, maintained by the community under the same PHP namespace, with releases that support Symfony 7. Install it together with the Vite bundle and the Inertia React adapter:

composer require skipthedragon/inertia-bundle pentatrion/vite-bundle
npm install @inertiajs/react react react-dom
npm install --save-dev @vitejs/plugin-react vite-plugin-symfony

Because the adapter is a small community project, read its changelog before each Symfony upgrade and pin it in composer.json. Should it stall again, the bundle is small enough to fork and maintain yourself; that is a real option, not a disaster.

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 small change. The inertia(page) helper renders the root element that React mounts into, and inertiaHead(page) outputs head tags when you use SSR:

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
  {{ vite_entry_link_tags('app') }}
  {{ inertiaHead(page) }}
</head>
<body>
  {{ inertia(page) }}
  {{ 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\Architecture\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 needs one piece of understanding but almost no code. Inertia's HTTP client looks for an XSRF-TOKEN cookie and, when it finds one, sends its value in an X-XSRF-TOKEN header on every request. Symfony does not set that cookie by itself; the Inertia bundle does. With its csrf option enabled (the default), the bundle issues the cookie and rejects Inertia requests whose header does not match. If you rename the cookie or header, change both the bundle configuration and the client options (xsrfCookieName and xsrfHeaderName), or every form submission will fail.

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:

inertia:
  ssr:
    enabled: true
    url: 'http://127.0.0.1:13714/render'

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. useForm() sends the form data as JSON, which the Symfony Form component does not read out of the box, so the simpler route is a DTO plus the Validator. Map the violations to a flat errors prop keyed by field name:

#[Route('/projects/{id}', methods: ['PUT'])]
public function update(
    Project $project,
    Request $request,
    SerializerInterface $serializer,
    ValidatorInterface $validator,
): Response {
    $input = $serializer->deserialize($request->getContent(), ProjectInput::class, 'json');
    $violations = $validator->validate($input);

    if (count($violations) > 0) {
        $errors = [];
        foreach ($violations as $violation) {
            $errors[$violation->getPropertyPath()] = $violation->getMessage();
        }

        return $this->inertia->render('Projects/Edit', [
            'project' => ProjectView::fromEntity($project),
            'errors' => $errors,
        ]);
    }

    $this->projectService->update($project, $input);

    return $this->redirectToRoute('project_index', [], Response::HTTP_SEE_OTHER);
}

On the React side, useForm() exposes the messages as errors.fieldName, ready to display inline. Two details matter here. Redirect with status 303 after a successful PUT, PATCH, or DELETE, otherwise the browser repeats the original method on the redirect target. And if you prefer the Laravel convention of redirecting back after a failed submission, store the errors in a flash message and share them through the listener shown earlier instead of rendering them directly.

What Inertia 2 and 3 Add

This post started out with Inertia 1.x in mind. Two major versions have shipped since, and both matter for Symfony teams:

  • Inertia 2 (December 2024) added deferred props, prefetching links on hover, polling, infinite scrolling helpers, and asynchronous requests that do not block navigation. Deferred props in particular help slow admin pages: the page renders right away and an expensive widget loads in a second request.
  • Inertia 3 (March 2026) replaced Axios with its own small HTTP client, added a useHttp hook for requests that are not page visits, and brought first-class optimistic updates.

The client-side features work with any server adapter. The server-side ones, such as deferred props, need support in the Symfony bundle, and the community bundle follows the Laravel adapter with a delay. Before upgrading the npm package, check which protocol features your bundle version supports and test a few pages; if something is missing, stay on the previous Inertia major until the adapter catches up.

Inertia vs Symfony UX Turbo and Live Components

Symfony has its own answer to "Twig is not enough": Symfony UX, with Turbo for SPA-like navigation and Live Components for interactive widgets rendered on the server. The two approaches solve the same problem in different places.

  • Symfony UX keeps Twig. Your templates stay, and interactivity comes from Stimulus controllers, Turbo frames, and Live Components. There is no separate JavaScript component tree and no React knowledge needed.
  • Inertia replaces Twig with React. Every migrated page becomes a React component, which is the right move when the team already writes React, needs a component library, or has pages with complex client-side state.

If the team is mostly PHP developers and the pages are forms and tables, start with Symfony UX or HTMX; our comparison of HTMX for Symfony admin panels covers where that approach ends. If frontend developers are asking for React anyway, Inertia gets them there without an API layer.

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.