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.
Table of Contents
- 1. What Symfony with Inertia.js fundamentally changes
- 2. Installation: backend adapter and frontend bridge
- 3. The first Inertia controller with props
- 4. Client side routing without duplicating backend routing
- 5. Passing forms and validation errors through to React
- 6. Shared data: making auth state available on every page
- 7. Partial reloads: reloading only the props you need
- 8. Server side rendering for SEO relevant pages
- 9. Symfony with Inertia.js compared to API Platform
- 10. Summary
- 11. FAQ
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.