All articles
12 September 2026
7 min read

Modernizing a server-rendered front end without an SPA

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

A white sash window with a single pane being replaced; its edge is blue.

When the front end of a legacy system is up for modernization, the answer is usually settled before the question is asked: a single-page application, a framework, an API behind it. That is one route, it is expensive, and in many cases it answers a question nobody raised.

The question that matters is not "how do we rebuild the front end" but "what is wrong with the one we have". And the answers to that are remarkably concrete: the page takes ages, a form loses what was typed into it, a table cannot be sorted, a search takes three clicks.

This article describes how to fix those things in a server-rendered application without changing the framework. It is not written against single-page applications. It is written against the assumption that they are the only way.

When a single-page application really is right

So that it is clear what this is measured against: there are cases where the switch is the right decision, and they share one trait. The state in the browser is rich and it lives for a long time.

A planning tool where somebody moves things around for two hours. An editor. A surface that continuously merges and refreshes data from several sources. A tool that has to keep working without a connection.

What is not a reason: forms, lists, searches, detail pages, admin areas. That is the bulk of what business applications do, and it is exactly what server-rendered pages were built for.

So the honest question before the decision is: how much state does the browser hold, and for how long? If the answer is "one form, until it is submitted", changing the framework solves a problem that is not there and creates several that are: an API, two frameworks, two deployment paths, a login in two places.

The three problems that actually annoy people

In the applications I have seen it was almost always the same three, and all three can be solved without changing the framework.

The page takes too long. Usually not because of the front end but because of the queries behind it. A page that runs forty queries will not get faster from any front end.

A task needs too many full page loads. Sorting, filtering, paging, deleting a row: each of them reloads the whole page, and the position in the document is lost.

There is no feedback. The user clicks, nothing happens for two seconds, they click again. That is not a display problem, it is a data problem: the operation runs twice.

The first is a backend job. For the second and the third there is a middle route, and it has been well supported again for some years now.

Swapping parts of the page instead of all of it

The approach is old and was out of fashion for a while: the server still delivers HTML, just not always the whole page. A click requests a fragment, and that fragment replaces what was there in place.

This needs no library, it needs about twenty lines. Anyone who wants one will find mature implementations in htmx or Turbo; the principle stays the same.

// Anything that reloads a fragment carries data-target.
// For those requests the server returns the fragment only.
document.addEventListener('click', async (e) => {
  const a = e.target.closest('a[data-target]');
  if (!a) return;
  e.preventDefault();

  const target = document.querySelector(a.dataset.target);
  target.setAttribute('aria-busy', 'true');

  const response = await fetch(a.href, { headers: { 'X-Fragment': '1' } });
  target.innerHTML = await response.text();
  target.removeAttribute('aria-busy');

  // Carry the address along so back and reload keep working.
  // Without this line the partial update is a trap.
  history.pushState({}, '', a.href);
});

On the server side the difference is one branch at the end, and the decisive part is that everything before it stays untouched:

public function list(Request $request): Response
{
    $orders = $this->search->find($request->query->all());

    // The same data path, two outputs. Build two methods here and you
    // will have two truths in six months.
    if ($request->headers->has('X-Fragment')) {
        return $this->render('order/_table.html.twig', [
            'orders' => $orders,
        ]);
    }

    return $this->render('order/list.html.twig', [
        'orders' => $orders,
    ]);
}

That puts sorting, filtering and paging at a few days of work, and the page stays fully usable without JavaScript. That is not a side effect, it is the reason this route holds: if the script does not load, the page simply loads in full.

What breaks in a swap when nobody is watching

The part that guides to this approach regularly leave out: replacing a fragment throws away the elements that were inside it. Three things hang off that, and all three only surface once somebody complains.

Keyboard focus disappears. Someone who triggered "next page" from the keyboard ends up back at the top of the document after the swap, because the focused element no longer exists. To a mouse user this is invisible; to everybody else the page is unusable from there on.

Nobody says that anything changed. A full page load announces itself to a screen reader by itself, a swapped fragment does not. The table is freshly sorted and the announcement never comes.

The scroll position jumps when the new fragment is shorter than the old one.

The fix is a few lines, and it belongs in the same function as the swap itself, not in a later pass:

// After the swap: set focus and announce the change.
// The target carries tabindex="-1" so that it can be focused without
// sitting in the tab order.
target.focus({ preventScroll: true });

// aria-live sits on a separate element that is always present.
// On the swapped fragment itself it would not work: the region has to
// be there before its content changes.
document.getElementById('status').textContent = target.dataset.status;

Implementing this with htmx or Turbo hands you part of it for free, but not all of it: the announcement stays a decision somebody has to make either way. And whoever writes the twenty lines themselves writes twenty-five.

Feedback and the double click

The third problem is the one that does real damage, and two measures settle it.

The operation becomes visible by disabling the button on submit. That is one line, and it is missing in grown applications almost every time:

document.addEventListener('submit', (e) => {
  const button = e.target.querySelector('[type=submit]');
  if (!button) return;
  button.disabled = true;
  button.dataset.before = button.textContent;
  button.textContent = 'Saving...';
});

You must not rely on it. Submit twice before the script takes hold, or reload the page, and the same operation happens twice. So the measure that carries weight sits on the server: the form carries an identifier, and the server remembers for a short while which identifiers it has processed.

// The identifier is created when the form is rendered and sits in it as a
// hidden field. Submitted twice means processed once.
if (!$this->once->claim($request->get('operation_id'), 300)) {
    // Not an error for the user: they see the result of the first
    // submission, which is what they expect.
    return $this->redirectToRoute('order_show', ['no' => $existing]);
}

That is the same idea as with jobs in a queue, only at the entrance: an action may arrive twice and must take effect once.

One level down sits the cache, which often costs more than it saves: The cache as a debt.

The load time is almost never the front end

Before anything is done to the surface, one measurement is worth half an hour and regularly changes the entire discussion: how much of the time until the page is finished goes to the server, and how much to loading in the browser?

In most grown applications the ratio is unambiguous: the server takes eight hundred milliseconds, the browser two hundred. A new front end would improve the two hundred.

What helps on the server side is almost always the same, and it has nothing to do with presentation: find the query inside the loop and remove it, add the missing index, cache the expensive calculation. Those are days, not months, and they work on every page at once.

Where time is still left in the browser after that, it is usually three things: fonts that hold up painting, images without dimensions, and third-party scripts loaded before the content. All three can be fixed one at a time, and none of them justifies changing the framework.

When there really is a second consumer, the order is the interface first: Putting an API in front of the monolith.

The limit of this route

So that the article does not promise more than it delivers: the middle route has a clear limit, and it is reached when one of the following three situations occurs.

The state in the browser turns complex. Once three fragments depend on each other and one has to refresh another, you are rebuilding a state manager, and badly. From that point on a framework is the more honest choice.

There is a second consumer. A mobile app, a partner, a second front end. Then you need an API anyway, and then the question about the front end is a different one.

The surface is the product. Where the interaction itself is the value rather than the data behind it, the effort pays.

In all three cases an order still holds, and it gets reversed regularly: the API first, the front end second. What that means for the cut is described under the term API-first. A new front end hanging directly off the monolith's page controllers carries the cost of a framework switch and none of its benefits.

And the sentence that holds for most business applications: two weeks spent on the three problems above give users more than six months of framework switching, and they do not rule it out. How I develop systems like these is described on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.