Alle Artikel
12. September 2026
7 mins

Server-gerenderte Oberflächen modernisieren, ohne SPA

Von Tim Rutte, Cloud & Software ArchitectThemaBackend & Plattformen

Ein weißes Sprossenfenster, bei dem eine einzige Scheibe ersetzt wird; ihr Rand ist blau.

Wenn an einem Altsystem die Oberfläche modernisiert werden soll, steht die Antwort meist vorher fest: eine Einseitenanwendung, ein Framework, eine Schnittstelle dahinter. Das ist ein Weg, er ist teuer, und in vielen Fällen ist er die Antwort auf eine Frage, die niemand gestellt hat.

Die Frage, um die es geht, lautet nicht „wie bauen wir das Frontend neu", sondern „was stört an der heutigen Oberfläche". Und die Antworten darauf sind erstaunlich konkret: Die Seite lädt lange, ein Formular verliert Eingaben, eine Tabelle ist nicht sortierbar, eine Suche braucht drei Klicks.

Dieser Artikel beschreibt, wie man diese Dinge in einer server-gerenderten Anwendung behebt, ohne den Rahmen zu wechseln. Er ist nicht gegen Einseitenanwendungen geschrieben, sondern gegen die Annahme, dass sie der einzige Weg sind.

Wann eine Einseitenanwendung wirklich richtig ist

Damit klar ist, wogegen abgegrenzt wird: Es gibt Fälle, in denen der Wechsel die richtige Entscheidung ist, und sie haben ein gemeinsames Merkmal. Der Zustand im Browser ist reichhaltig und lebt lange.

Ein Planungswerkzeug, in dem jemand zwei Stunden lang Dinge verschiebt. Ein Editor. Eine Oberfläche, die dauerhaft Daten von mehreren Quellen zusammenführt und aktualisiert. Ein Werkzeug, das auch ohne Verbindung arbeiten soll.

Was dagegen kein Grund ist: Formulare, Listen, Suchen, Detailseiten, Verwaltungsbereiche. Das ist der überwiegende Teil dessen, was Geschäftsanwendungen tun, und es ist genau das, wofür server-gerenderte Seiten gebaut sind.

Die ehrliche Frage vor der Entscheidung lautet deshalb: Wie viel Zustand hält der Browser, und wie lange? Wenn die Antwort „ein Formular, bis zum Absenden" ist, löst ein Wechsel des Rahmens ein Problem, das nicht da ist, und erzeugt dafür mehrere neue: eine Schnittstelle, zwei Rahmen, zwei Ausrollwege, eine Anmeldung an zwei Stellen.

Die drei Probleme, die tatsächlich stören

In den Anwendungen, die ich gesehen habe, waren es fast immer dieselben drei, und alle drei sind ohne Rahmenwechsel lösbar.

Die Seite lädt zu lange. Meistens nicht wegen der Oberfläche, sondern wegen der Abfragen dahinter. Eine Seite, die vierzig Abfragen macht, wird durch kein Frontend schneller.

Ein Vorgang braucht zu viele vollständige Neuladungen. Sortieren, filtern, blättern, eine Zeile löschen: Jedes davon lädt die ganze Seite neu, und die Position im Dokument geht verloren.

Rückmeldungen fehlen. Der Nutzer klickt, nichts passiert für zwei Sekunden, er klickt noch einmal. Das ist kein Darstellungsproblem, es ist ein Datenproblem: Der Vorgang wird zweimal ausgeführt.

Das erste ist eine Backend-Aufgabe. Für das zweite und dritte gibt es einen Mittelweg, der seit einigen Jahren wieder gut unterstützt ist.

Teile der Seite austauschen statt der ganzen

Der Ansatz ist alt und war zwischenzeitlich aus der Mode: Der Server liefert weiterhin HTML, aber nicht immer die ganze Seite. Ein Klick fordert einen Ausschnitt an, und der ersetzt an Ort und Stelle das, was dort stand.

Dafür braucht es keine Bibliothek, sondern etwa zwanzig Zeilen. Wer eine will, findet mit htmx oder Turbo ausgereifte Umsetzungen; das Prinzip bleibt dasselbe.

// Alles, was einen Ausschnitt nachlaedt, traegt data-ziel.
// Der Server liefert fuer solche Anfragen nur den Ausschnitt.
document.addEventListener('click', async (e) => {
  const a = e.target.closest('a[data-ziel]');
  if (!a) return;
  e.preventDefault();

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

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

  // Adresse mitfuehren, damit Zurueck und Neuladen funktionieren.
  // Ohne diese Zeile ist die Teilaktualisierung eine Falle.
  history.pushState({}, '', a.href);
});

Auf der Serverseite ist der Unterschied eine Verzweigung am Ende, und der entscheidende Teil ist, dass die Logik davor unverändert bleibt:

public function liste(Request $anfrage): Response
{
    $bestellungen = $this->suche->finden($anfrage->query->all());

    // Derselbe Datenpfad, zwei Ausgaben. Wer hier zwei Methoden baut,
    // hat in einem halben Jahr zwei Wahrheiten.
    if ($anfrage->headers->has('X-Ausschnitt')) {
        return $this->render('bestellung/_tabelle.html.twig', [
            'bestellungen' => $bestellungen,
        ]);
    }

    return $this->render('bestellung/liste.html.twig', [
        'bestellungen' => $bestellungen,
    ]);
}

Der Aufwand für Sortieren, Filtern und Blättern liegt damit bei wenigen Tagen, und die Seite bleibt ohne JavaScript vollständig benutzbar. Das ist kein Nebeneffekt, sondern der Grund, warum dieser Weg trägt: Wenn das Skript nicht lädt, lädt die Seite eben ganz.

Was beim Austausch kaputtgeht, wenn niemand hinsieht

Der Teil, der in Anleitungen zu diesem Ansatz regelmäßig fehlt: Wer einen Ausschnitt ersetzt, wirft die Elemente weg, die darin standen. Drei Dinge hängen daran, und alle drei fallen erst auf, wenn sich jemand beschwert.

Der Tastaturfokus verschwindet. Wer mit der Tastatur auf „Nächste Seite" ausgelöst hat, steht nach dem Austausch wieder am Anfang des Dokuments, weil das fokussierte Element nicht mehr existiert. Für einen Mausnutzer ist das unsichtbar, für alle anderen ist die Seite danach unbedienbar.

Niemand sagt, dass sich etwas geändert hat. Ein vollständiges Neuladen kündigt sich beim Screenreader von selbst an, ein ausgetauschter Ausschnitt nicht. Die Tabelle ist neu sortiert und die Ansage bleibt aus.

Die Scrollposition springt, wenn der neue Ausschnitt kürzer ist als der alte.

Die Behebung sind wenige Zeilen, und sie gehören in dieselbe Funktion wie der Austausch selbst, nicht in einen späteren Durchgang:

// Nach dem Austausch: Fokus setzen und die Aenderung ansagen.
// Das Ziel traegt tabindex="-1", damit es fokussierbar ist, ohne in
// der Tabulatorreihenfolge zu stehen.
ziel.focus({ preventScroll: true });

// aria-live sitzt auf einem eigenen, immer vorhandenen Element.
// Auf dem ausgetauschten Ausschnitt selbst wuerde es nicht wirken:
// Der Bereich muss schon dastehen, bevor sich sein Inhalt aendert.
document.getElementById('meldung').textContent = ziel.dataset.meldung;

Wer den Ansatz mit htmx oder Turbo umsetzt, bekommt einen Teil davon geschenkt, aber nicht alles: Die Ansage bleibt in beiden Fällen eine Entscheidung, die jemand treffen muss. Und wer die zwanzig Zeilen selbst schreibt, schreibt eben fünfundzwanzig.

Rückmeldung und Doppelklick

Das dritte Problem ist das mit dem echten Schaden, und es ist mit zwei Maßnahmen erledigt.

Sichtbar wird der Vorgang durch ein Sperren des Knopfes beim Absenden. Das ist eine Zeile, und sie fehlt in gewachsenen Anwendungen fast immer:

document.addEventListener('submit', (e) => {
  const knopf = e.target.querySelector('[type=submit]');
  if (!knopf) return;
  knopf.disabled = true;
  knopf.dataset.vorher = knopf.textContent;
  knopf.textContent = 'Wird gespeichert...';
});

Verlassen darf man sich darauf nicht. Wer zweimal absendet, bevor das Skript greift, oder die Seite neu lädt, erzeugt denselben Vorgang zweimal. Die belastbare Maßnahme liegt deshalb auf dem Server: Das Formular trägt eine Kennung, und der Server merkt sich für kurze Zeit, welche Kennungen er verarbeitet hat.

// Die Kennung entsteht beim Anzeigen des Formulars und liegt als
// verstecktes Feld darin. Zweimal abgesendet heisst einmal verarbeitet.
if (!$this->einmalig->beanspruchen($anfrage->get('vorgang_id'), 300)) {
    // Kein Fehler fuer den Nutzer: er sieht das Ergebnis des ersten
    // Absendens, so wie er es erwartet.
    return $this->redirectToRoute('bestellung_zeigen', ['nr' => $vorhandene]);
}

Das ist derselbe Gedanke wie bei Aufträgen in einer Warteschlange, nur an der Eingangsseite: Eine Aktion darf zweimal ankommen und muss einmal wirken.

Eine Ebene tiefer liegt der Zwischenspeicher, der oft mehr kostet, als er spart: Der Cache als Schuld.

Die Ladezeit ist fast nie das Frontend

Bevor an der Oberfläche etwas gemacht wird, lohnt eine Messung, die eine halbe Stunde kostet und regelmäßig die ganze Diskussion verändert: Wie viel der Zeit bis zur fertigen Seite geht für den Server drauf, und wie viel für das Laden im Browser?

In den meisten gewachsenen Anwendungen ist das Verhältnis deutlich: Der Server braucht achthundert Millisekunden, der Browser zweihundert. Ein neues Frontend würde die zweihundert verbessern.

Was auf der Serverseite hilft, ist fast immer dasselbe, und es hat nichts mit Darstellung zu tun: die Abfrage in der Schleife finden und beseitigen, einen fehlenden Index setzen, eine teure Berechnung zwischenspeichern. Das sind Tage, nicht Monate, und sie wirken auf jeder Seite gleichzeitig.

Wo danach noch Zeit im Browser liegt, sind es meist drei Dinge: Schriften, die das Zeichnen aufhalten, Bilder ohne Größenangabe, und Skripte von Dritten, die vor dem Inhalt geladen werden. Alle drei sind einzeln zu beheben und keines davon rechtfertigt einen Rahmenwechsel.

Wenn es tatsächlich einen zweiten Verbraucher gibt, gilt die Reihenfolge erst die Schnittstelle: Eine API vor den Monolithen setzen.

Die Grenze dieses Wegs

Damit der Artikel nicht mehr verspricht, als er hält: Der Mittelweg hat eine klare Grenze, und sie ist erreicht, wenn eine der folgenden drei Situationen eintritt.

Der Zustand im Browser wird komplex. Wenn drei Ausschnitte voneinander abhängen und einer den anderen aktualisieren muss, baut man einen Zustandsverwalter nach, und zwar schlecht. Ab diesem Punkt ist ein Rahmen die ehrlichere Wahl.

Es gibt einen zweiten Verbraucher. Eine mobile Anwendung, ein Partner, ein zweites Frontend. Dann braucht es ohnehin eine Schnittstelle, und dann ist die Frage nach dem Frontend eine andere.

Die Oberfläche ist das Produkt. Wo die Bedienung selbst der Wert ist und nicht die Daten dahinter, lohnt der Aufwand.

In allen drei Fällen gilt trotzdem eine Reihenfolge, und sie wird regelmäßig umgedreht: erst die Schnittstelle, dann das Frontend. Was das für den Schnitt bedeutet, steht beim Begriff API-first. Ein neues Frontend, das direkt an den Seitenaufrufen des Monolithen hängt, hat den Aufwand eines Rahmenwechsels und keinen seiner Vorteile.

Und der Satz, der für die meisten Geschäftsanwendungen gilt: Zwei Wochen an den drei Problemen oben bringen den Nutzern mehr als sechs Monate Rahmenwechsel, und sie schließen ihn nicht aus. Wie ich solche Systeme weiterentwickle, steht auf einer eigenen Seite.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.