Combining Symfony with Inertia.js: SPA Feel Without a Dedicated API
AI generated
SF
{ }
Symfony · Inertia.js · React · SPA
Combining Symfony with Inertia.js
SPA feel without a dedicated API

Combining Symfony with Inertia.js means writing controllers largely the same classic, Twig-like way, except that at the end a React component name with props is returned instead of HTML, making the entire frontend feel like a single page application, without a separate REST or GraphQL API ever coming into existence.

19 min read Symfony Inertia.js · React · Props Symfony 7.x · PHP 8.4

1. What Symfony with Inertia.js fundamentally changes

Anyone combining Symfony with Inertia.js deliberately skips the otherwise usual route of building a separate JSON API that a React frontend consumes through fetch calls. Instead, the Symfony controller remains the single source of truth for a page: it loads data from Doctrine, checks permissions through voters, and at the end returns the name of a React component together with the required props, instead of a Twig template. Inertia takes care of swapping the page in the browser in the background, without triggering a full page reload.

The decisive conceptual difference between Symfony with Inertia.js and a classic API plus SPA architecture: there is no second interface that needs to be maintained alongside the server logic. Routing, authorization and serialization all happen at exactly one place in the Symfony controller, not duplicated across a controller and an API resource. That considerably reduces maintenance effort, especially in projects where the API only ever serves the project's own frontend and no external consumers.

For teams that value React components for their interactivity but want to avoid the complexity of a full API layer with OpenAPI documentation and versioning, Symfony with Inertia.js is a pragmatic middle ground between classic, server rendered Symfony and a fully decoupled single page application.

2. Installation: backend adapter and frontend bridge

Setting up Symfony with Inertia.js requires two parts: the PHP side adapter inertiajs/inertia-laravel only exists for Laravel, for Symfony you instead use the community library inertiajs/inertia-symfony or a lightweight custom service that implements Inertia's HTTP contract details. On the frontend side, the npm package @inertiajs/react handles setting up the React root and loading the matching page on navigation.

An important point when configuring Symfony with Inertia.js: the backend must react to the special HTTP header X-Inertia. When the header is set, the server responds with plain JSON instead of a full HTML document, because Inertia already has a running page in the frontend and only needs the new props. On a regular page visit without this header, Symfony delivers an initial HTML document with embedded JSON, from which React hydrates on first load.


# Backend: install the Inertia HTTP contract adapter for Symfony
composer require inertiajs/inertia-symfony

# Frontend: install the Inertia React client
bin/console importmap:require @inertiajs/react
bin/console importmap:require react react-dom

3. The first Inertia controller with props

A Symfony controller that uses Inertia.js is structurally almost indistinguishable from an ordinary controller. Instead of calling $this->render('template.html.twig', [...]), it returns an Inertia response with the name of the React component and an array of props. The component itself lives as a .tsx file in the frontend directory and receives the props as regular React props, typed through TypeScript interfaces.

The advantage of this pattern with Symfony with Inertia.js: the shape of the props is defined centrally in the controller, exactly where the authorization check via denyAccessUnlessGranted also happens. There is no separate serializer configuration and no additional API resource class that would need to be kept in sync with the controller.


// src/Controller/OrderController.php
namespace App\Controller;

use App\Repository\OrderRepository;
use Inertia\Inertia;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class OrderController extends AbstractController
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
    ) {
    }

    #[Route('/orders', name: 'app_orders_index', methods: ['GET'])]
    public function index(): Response
    {
        $this->denyAccessUnlessGranted('ROLE_USER');

        $orders = $this->orderRepository->findRecentForCurrentUser(limit: 25);

        // Renders the React component "Orders/Index" with these props
        return Inertia::render('Orders/Index', [
            'orders' => array_map(
                static fn ($order) => [
                    'id' => $order->getId(),
                    'reference' => $order->getReference(),
                    'total' => $order->getTotalGross(),
                    'status' => $order->getStatus()->value,
                ],
                $orders,
            ),
        ]);
    }
}

4. Client side routing without duplicating backend routing

A common misunderstanding with Symfony with Inertia.js: no second routing system emerges in the frontend that would need to be maintained alongside Symfony's routes.yaml. Navigation between pages runs through Inertia's Link component, which internally targets a regular Symfony URL, for example /orders/42. The browser visibly changes the URL in the address bar, but Inertia intercepts the click, prevents a full page reload, and only fetches the new props.

For Symfony with Inertia.js, that means the single source of truth for URLs stays Symfony's routing component. To enable type safe URL generation in React code, many projects additionally use a small code generation tool that turns the Symfony routes into a TypeScript file with helper functions, so that route changes in the backend immediately show up as compile errors in the frontend, instead of only surfacing later as a broken link at runtime.

5. Passing forms and validation errors through to React

Forms are the area where Symfony with Inertia.js shows its biggest strength compared to a pure API architecture. The Inertia frontend client provides a useForm hook that holds form data, sends the submit as a POST to the Symfony route, and on a validation error response automatically sorts the errors into an errors object, without any custom error handling code needing to be written in the frontend.

On the Symfony side, it is enough to return an Inertia specific error response on a failed validation with the regular Symfony validator, containing the field to error message mapping as an associative array. Inertia automatically recognizes this response by the HTTP status code 422 and updates the form errors in React state, without leaving the current page. For Symfony with Inertia.js, this means: the exact same validation logic used for classic Twig forms keeps working unchanged.


// resources/js/Pages/Orders/Create.tsx
import { useForm } from '@inertiajs/react';

export default function Create() {
    // Inertia's useForm tracks data, errors and submission state together
    const { data, setData, post, processing, errors } = useForm({
        customerName: '',
        total: 0,
    });

    function submit(event: React.FormEvent) {
        event.preventDefault();
        post('/orders'); // Sends a POST to the Symfony route, errors flow into `errors`
    }

    return (
        <form onSubmit={submit}>
            <input
                value={data.customerName}
                onChange={(e) => setData('customerName', e.target.value)}
            />
            {errors.customerName && <span>{errors.customerName}</span>}
            <button type="submit" disabled={processing}>Save</button>
        </form>
    );
}

6. Shared data: making auth state available on every page

Not every piece of information a React page needs should be repeated in every single controller, for example the logged in user's name or an unread notification count. Symfony with Inertia.js solves this through shared props, defined centrally in one place and automatically mixed into every Inertia response, regardless of which controller handles the request.

This shared data is provided through a central, middleware like mechanism that runs before every Inertia response. For Symfony with Inertia.js, an event listener on kernel.controller or a dedicated service works well for this, reading the current security token and feeding the relevant user data into the shared props before the actual controller logic runs.


// src/EventListener/InertiaSharedDataListener.php
namespace App\EventListener;

use Inertia\Inertia;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::CONTROLLER)]
final class InertiaSharedDataListener
{
    public function __construct(
        private readonly Security $security,
    ) {
    }

    public function __invoke(ControllerEvent $event): void
    {
        $user = $this->security->getUser();

        // Shared props are merged into every Inertia response automatically
        Inertia::share('auth', [
            'user' => $user ? ['name' => $user->getUserIdentifier()] : null,
        ]);
    }
}

7. Partial reloads: reloading only the props you need

A page with several independent data blocks, for example an order list and a separate statistics widget, does not necessarily need to recompute every prop when a filter changes. Symfony with Inertia.js supports partial reloads, where the frontend explicitly requests which props to update through the only parameter. On the Symfony side, this can be modeled through Inertia::lazy(), so that an expensive prop value is only computed once it has actually been requested.

This mechanism is particularly valuable for computationally heavy aggregations, for example a revenue statistic that joins several tables. Without partial reloads, Symfony with Inertia.js would rerun this calculation on every small filter change, even when only the order list is affected. With Inertia::lazy(), the expensive computation is skipped as long as the frontend does not explicitly request that particular prop.

8. Server side rendering for SEO relevant pages

By default, Symfony with Inertia.js renders the React components exclusively in the browser, which is usually enough for backend applications such as order management or customer portals, but problematic for publicly accessible, SEO relevant pages, because search engine crawlers without executed JavaScript only see an empty page. For these cases, Inertia supports optional server side rendering through a separate Node.js process that renders the same React components to HTML on the server, before Symfony delivers the response.

Running this SSR process is the one point where Symfony with Inertia.js does need a Node.js runtime in production after all, alongside PHP-FPM. In practice, this Node process runs as its own systemd service or container next to the PHP process, and Symfony internally forwards requests for SSR relevant pages to it, before the final HTML goes to the browser.

9. Symfony with Inertia.js compared to API Platform

The decision between Symfony with Inertia.js and a full API architecture using API Platform largely depends on whether the frontend will ever be consumed by a client other than its own web application. The following overview compares the most important differences.

Aspect Classic REST API + SPA Symfony with Inertia.js
Separate API layer needed Yes No
Client routing duplicates backend routing Usually yes No, Symfony stays the source
Validation error handling Custom built Built in via useForm
External API consumers possible Yes No, tightly coupled internally
SEO without extra effort Depends on setup Only with optional SSR process

Anyone who needs an API also consumed by a mobile app or external partners cannot avoid a real REST or GraphQL layer like API Platform. Symfony with Inertia.js is the right choice when the frontend exists exclusively for the project's own web application and no second interface needs to be maintained.

Mironsoft

Symfony development with a modern React frontend

SPA feel without having to maintain a second API?

We build Symfony applications with Inertia.js and React, including form validation, shared data and optional server side rendering for SEO relevant pages.

Architecture consulting

Evaluating Inertia.js versus API Platform for your use case

Implementation

Setting up controllers, React pages and shared props cleanly

SSR operations

Setting up server side rendering for public, SEO relevant pages

10. Summary

Combining Symfony with Inertia.js means keeping controllers centrally as the single source of truth for routing, authorization and data access, while React handles interactivity in the browser. Instead of a separate JSON API, controllers directly return React component names with props, forms use the built in useForm hook for validation errors, and shared props ensure that auth state and other global data do not need to be repeated in every controller.

The biggest trade off with Symfony with Inertia.js is giving up a standalone, externally consumable API. Anyone who does not need that saves considerable maintenance effort compared to a classic REST API plus SPA architecture. For public, SEO relevant pages, the extra effort of server side rendering through an accompanying Node.js process pays off, while internal applications work perfectly well without SSR.

Symfony with Inertia.js — The essentials at a glance

No second API

Controllers return component names and props directly, no separate JSON resource classes.

Forms built in

The useForm hook automatically handles validation errors through HTTP status code 422.

Shared props

Auth state and global data provided centrally through an event listener.

SSR optional

A Node.js process is only needed for public, SEO relevant pages, otherwise plain client rendering.

11. FAQ: Symfony with Inertia.js

1Need a separate REST API?
No, controllers return component names with props directly, no separate JSON API needed.
2A second routing system?
No, Symfony's routing stays the single source of truth for URLs.
3Validation errors to React?
Through a 422 response, automatically sorted by useForm into an errors object.
4Auth state on every page?
Through shared props, set by an event listener before every Inertia response.
5What are partial reloads?
Targeted reloading of individual props, combined with Inertia::lazy() for expensive computations.
6Good for SEO?
Only with additional server side rendering through a separate Node.js process.
7Serve a mobile app too?
Not directly, a real REST or GraphQL API is additionally needed for that.
8Difference from API Platform?
API Platform builds a full API for arbitrary consumers, Inertia couples more tightly and skips the API layer.
9Full page reload on navigation?
No, Inertia only fetches new props and swaps the component without a full reload.
10How type safe is the collaboration?
Props are plain JSON at runtime, TypeScript helps in the frontend, true end to end type checking needs extra tooling.