When Not to Reach for UX
Where server-rendered interactivity stops paying off, and what an API-first split with a client-side frontend actually costs.
Symfony UX works by keeping state on the server. That is both its strength and its boundary. Any interaction that needs the server's opinion costs a round trip, and round trips carry latency you do not control.
Where it fits
Forms, filters, search, tables, wizards, admin screens: anywhere the server already owns the data and the user expects to wait a moment for it. For behaviour that is purely local, write a plain Stimulus controller instead and make no request at all. A character counter, a disclosure toggle and a confirmation dialog need no server.
Where it does not
Drag-and-drop canvases, rich text editors, anything redrawing while a pointer moves, offline use, optimistic updates, and multi-step client state that must survive a bad connection. A Live Component re-rendering on pointer move is a request on pointer move. If the interaction has its own model in the browser, something has to own that model, and a Twig template cannot.
The API-first split
The honest alternative is to stop rendering HTML for that part of the product. Symfony answers JSON; the frontend is a separate application with its own tooling and its own router. Symfony's own guidance points this way for React, Vue or Svelte front ends. The cost is real: two things to deploy, serialisation to maintain, authorisation rules expressed on both sides, and client-side routing. Pay it when the interaction demands it.
A mixed codebase is normal. Twig and Turbo for most routes, one client-rendered area where it earns its place.
Example
<?php
// src/Controller/Api/PlanController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Repository\PlanRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
// The route prefix and the name prefix both apply to every action below,
// so this class answers GET /api/plans as route api_plan_index.
#[Route('/api/plans', name: 'api_plan_')]
final class PlanController extends AbstractController
{
public function __construct(private readonly PlanRepository $plans)
{
}
#[Route('', name: 'index', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
// query is the query string. request would be the POST body.
$term = $request->query->getString('q');
// One query returns the summary rows as arrays, so nothing below
// touches a lazy relation and no further queries are issued.
$rows = $this->plans->findSummaries($term, 20);
return $this->json($rows);
}
}
When to use it
- Decide whether a drag-and-drop scheduler should be a Live Component or a client-side app.
- Serve an existing React admin from the same Symfony project, with Symfony as a JSON API.
- Replace a Live Component firing a request per pointer move with a Stimulus controller.
- Route every URL under /editor to one shell template so refreshes do not 404.
More examples
Local behaviour needs no request
Wired up with data-action="input->character-count#update", this runs on every keystroke and sends nothing. The same feature as a Live Component would be a POST per keystroke to compute a subtraction. The question to ask of any interaction is whether the server knows something the browser does not; when the answer is no, Stimulus alone is the cheaper and faster choice. Note that the server still enforces the length limit on submit, because this counter is a hint, not a constraint.
// assets/controllers/character_count_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'count'];
static values = { max: Number };
connect() {
this.update();
}
update() {
const remaining = this.maxValue - this.inputTarget.value.length;
this.countTarget.textContent = `${remaining} characters left`;
this.countTarget.hidden = remaining === this.maxValue;
}
}
Handing server data to a client-rendered area
This is the middle path: the page is still a normal Twig route, and one element is a client-owned island booted by a Stimulus controller. The plan is passed as a value, not fetched in a second request, and the save URL is generated by the router rather than hardcoded in JavaScript. Auto-escaping handles the JSON inside the attribute, so no |raw is needed or wanted. Use this when exactly one screen needs a client-side model and the rest of the application does not.
{# templates/editor/show.html.twig #}
{# The server renders the data once. From here the browser owns it: #}
{# dragging a wall redraws locally and sends nothing. #}
<div data-controller="floorplan-editor"
data-floorplan-editor-plan-value="{{ plan|json_encode }}"
data-floorplan-editor-save-url-value="{{ path('api_plan_update', {id: planId}) }}">
</div>
One shell route for a client-side router
When a section of the application is client-rendered, its URLs still have to resolve on the server, otherwise a refresh or a shared link returns 404. GET /editor/plans/7 returns 200 with the same shell page every time, and the client-side router reads the path and decides what to show. Keep this route narrow: a catch-all at the root would swallow every other URL in the project, including the JSON API the shell then calls.
<?php
// src/Controller/EditorShellController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EditorShellController extends AbstractController
{
// '.*' as a requirement lets the placeholder match slashes, which it
// does not by default, so /editor/plans/7/walls reaches this action.
// The default makes /editor itself match as well.
#[Route(
'/editor/{path}',
name: 'app_editor_shell',
requirements: ['path' => '.*'],
defaults: ['path' => ''],
methods: ['GET'],
)]
public function shell(): Response
{
return $this->render('editor/shell.html.twig');
}
}
Discussion