Die Frontend-Welt steht nicht still, und so gibt es mittlerweile zahlreiche Ansätze, wie Webanwendungen entwickelt werden können. Grob einteilen lassen sich die verschiedenen Ansätze in serverseitige Webanwendungen und Single-Page-Anwendungen.

Der erste Ansatz existiert seit jeher. Hier macht der Browser einen Request zum Server. Dieser liefert fertiges HTML aus, das er pro Request neu erzeugt oder bereits fertig vorliegen hat. Der Browser muss das HTML dann lediglich darstellen. Sofern die Anwendung ohne JavaScript arbeitet, erfolgen die weiteren Interaktionen dann z.B. über das Anklicken eines Links oder das Absenden eines Formulars. In beiden Fällen fordert der Browser eine vollständige, neue Seite vom Server an. Dieser Ansatz eignet sich sehr gut für eher statische Webanwendungen bzw. -seiten, wie etwa Nachrichtenportale.

Auf der anderen Seite gibt es die Single-Page-Anwendungen. Hier bekommt der Browser in der Regel mehr oder minder eine leere HTML-Seite zurück, die lediglich script-Elemente enthält, die auf JavaScript-Code verweisen. Die eigentliche Anwendung ist vollständig in JavaScript implementiert, und der JavaScript-Code ist dann auch für die vollständige Darstellung verantwortlich (HTML spielt hier keine oder nur eine sehr geringe Rolle).

Die Verwendung von JavaScript ermöglicht sehr feingranulare und schnelle Interaktionen und UI-Aktualisierungen. Als Folge einer Interaktion (z.B. einem Button-Click) kann die Anwendung die Darstellung direkt im Browser anpassen, ohne dafür über den Server gehen zu müssen. Dieser Ansatz eignet sich also für sehr interaktive Anwendungen, die möglicherweise fachlich auch gar keine „Seiten“ kennen, etwa Zeichenprogramme oder Kollaborationstools wie Slack oder Teams.

Zu den Konsequenzen dieses Ansatzes gehört, dass der Browser je nach Anwendungsgröße viel JavaScript runterladen und ausführen muss. Das kann zu schlechteren Startzeiten im Vergleich mit serverseitigen Anwendungen führen. Nicht nur, dass die benötigte Menge an Bandbreite gegenüber einer HTML-Seite höher sein kann. Das JavaScript muss der Browser nach dem Empfang erst einmal parsen und ausführen, bevor die Anwendung dann eine erste Darstellung erzeugen kann. Eventuell muss die Anwendung danach noch Server-Zugriffe machen, um die zur Darstellung benötigten Daten von der (REST) API des Servers abzufragen.

Interaktionsmöglichkeiten serverseitiger Anwendungen

Wenn man sich die große Bandbreite an Webanwendungen und deren ganz unterschiedlichen Aufgaben und Benutzungsmuster ansieht, stellt man fest, dass beide Ansätze ihre Berechtigung haben. Je nach Anforderungen kann man sich die passende Architektur auswählen. Allerdings gibt es auch Anwendungen, die nur schlecht auf die eine oder andere Seite dieser beiden Architekturen passen. Zahlreiche Anwendungen sind nicht rein statisch, aber auch nicht sehr interaktiv.

Abbildung: Webanwendungen entwickeln – Beispiel-Anwendung

Das folgende Beispiel, eine Webanwendung zur Darstellung von Kochrezepten, soll dies verdeutlichen. In Abbildung 1 ist eine Seite zu sehen, die Kochrezepte in einer Liste darstellt. Mit den Knöpfen oben rechts kann die Liste sortiert werden, und mit den Knöpfen am unteren Rand kann man durch die Liste blättern. Beides sind klassische Interaktionsmuster von Webanwendungen, und ohne Frage kann die Anwendung in dieser Form rein serverseitig zum Beispiel mit Spring WebMVC, DotNET oder PHP entwickelt und ausgeführt werden.

Im ersten Request liefert der Server die initiale Liste. Die „Knöpfe“ sind reguläre HTML a-Elemente, die Links zur nächsten Seite („?page=2“) bzw. ein Sortierkriterium  („?order_by=rating“) erhalten. Je nachdem, wie oft sich der Datenbestand ändert, kann der Server die fertigen HTML-Seiten sogar im Voraus statisch erzeugen oder zumindest cachen, so dass die Seiten nicht bei jedem Request neu erzeugt werden müssen.

In der Umsetzung typisch dafür ist das MVC-Pattern. Der Server mappt einen eingehenden Request auf eine Controller-Methode. Diese Methode ist dafür verantwortlich, die entsprechenden Daten (Model) zum Beispiel aus einer Datenbank zu ermitteln und die Ausgabe (das View) zu erzeugen. Hierfür wird häufig eine Template-Sprache verwendet.

Listing 1 zeigt exemplarisch einen stark vereinfachten und gekürzten Code mit Spring Boot, der die Rezepte-Seite rendert:

Der serverseitige Ansatz kommt aber an seine Grenzen, wenn es um Interaktionen in der Anwendung geht, die über Seitenwechsel hinausgehen, wo also ein a– oder form-Element nicht ausreicht.

In der Liste wird für jedes Rezept ein „Like“-Button dargestellt. Wenn auf diesen geklickt wird, soll das „Like“ für das Rezept serverseitig gespeichert und die neue Like-Anzahl dargestellt werden. Grundsätzlich lässt sich dieses Verhalten mit einem form-Element umsetzen. Der Like-Button wäre dann zum Beispiel der Submit-Button des Formulars.

Limitationen serverseitiger Anwendungen

Allerdings würde der Server in diesem Fall eine komplett neue Seite erzeugen und ausliefern müssen. Das kann zum Flickern der Darstellung führen. Außerdem ist es – ohne JavaScript – kaum möglich, zu verhindern, dass der Button mehrfach hintereinander gedrückt wird. Und durch das Ersetzen der ganzen Seite im Browser gehen auch alle anderen Daten im Client verloren.

Im oberen Teil der Seite etwa ist ein Textfeld vorhanden, mit dem man sich für einen Newsletter registrieren kann. Ist dieses Feld ausgefüllt und es wird auf den form-basierten Like-Button gedrückt, würde das Feld mit Eintreffen der neuen HTML-Seite vom Browser normalerweise zurückgesetzt werden. Das zu verhindern ist realistisch ohne JavaScript nicht möglich.

Die Beispiel-Anwendung enthält im oberen Bereich außerdem eine Art Eieruhr, die natürlich weiterlaufen soll, auch wenn auf den Like-Button geklickt wird. Auch das geht nicht, wenn die ganze Seite vom Browser ausgetauscht wird, weil dann auch der DOM-Zustand (und damit der Timer) zurückgesetzt wird.

JavaScript in serverseitigen Anwendungen

Um eine feingranulare Aktualisierung der UI vorzunehmen, muss also zwingend JavaScript im Client eingesetzt werden. Hier gibt es nun mehrere Möglichkeiten. Beim Klicken auf den Like-Button könnte eine (REST) API im Server aufgerufen werden. Diese nimmt den Request entgegen, speichert den Like und liefert die neue Anzahl der Likes zurück. Der Client wartet auf das Ergebnis des Requests und passt die Darstellung an.

Dieser Ansatz hat allerdings mehrere Konsequenzen, die gerade in großen Anwendungen zu Problemen durch schwer verständlichen Code führen können. Zum einen muss die Anwendung nun nicht nur eine Template-Sprache zum Erzeugen des HTML-Codes verwenden, sondern es muss auch JavaScript-Code programmiert werden. Dieser JavaScript-Code arbeitet dann zur Laufzeit im Client auf dem serverseitig generierten HTML (bzw. auf dem DOM, der aus dem HTML erzeugt wurde). Es muss also in der Entwicklung darauf geachtet werden, dass JavaScript-Logik und HTML/DOM zusammenpassen.

An einer isolierten Stelle ist das durchaus machbar, aber in komplexeren Anwendungen kann es zu einer Herausforderung bei der (Weiter-)Entwicklung werden (zum Beispiel, wenn mehrere Stellen aktualisiert werden müssen). Eine Alternative kann der Einsatz der Bibliothek HTMX sein. Hier kann im HTML-Code deklarativ beschrieben werden, dass beim Klicken auf ein Element ein Server Request ausgelöst werden soll. Der Server muss in dem Fall ein HTML-Schnipsel zurückliefern, der von HTMX an einer markierten Stelle im DOM eingesetzt wird.

Listing 2 zeigt ein einfaches Beispiel dafür:

Aber auch was hier einfach aussieht, hat in der Praxis seine Tücken. Der Server muss jetzt in der Lage sein, eine komplette HTML-Seite, aber auch einzelne Fragmente davon ausliefern zu können. Ob und wie gut das funktioniert, hängt stark von den serverseitigen Frameworks und der Template-Sprache ab. (Der gezeigte HTML-Ausschnitt ist das fertig gerenderte Fragment, nicht der Template-Code zum Rendern des Fragments) und wie isoliert Aktualisierungen erfolgen können. Davon abgesehen ist HTMX im Wesentlichen auf das Arbeiten mit Server Requests beschränkt. Andere Interaktionen (z.B. ein Zeichenzähler an einem Textfeld) lassen sich damit nicht umsetzen.

Die Verwendung des serverseitigen Renders mit eingestreuten JavaScript-Schnipseln wird häufig verwendet, kommt aber gerade bei größeren Anwendungen an seine Grenzen, denn man hat es plötzlich mit einem Strauß von Programmiersprachen, Frameworks und Konzepten zu tun. Der Code kann schnell unübersichtlich werden, ist schwierig zu testen, und das JavaScript bietet keine Typsicherheit.

Der Fullstack-Ansatz am Beispiel Next.js

Einen anderen Ansatz gehen die sogenannten Meta- oder Fullstack-Frameworks wie Astro, Qwik oder Next.js. Ähnlich wie bei den Single-Page-Anwendungen wird auch hier die Anwendung in JavaScript (bzw. TypeScript) entwickelt. Allerdings werden große Teile der Anwendung serverseitig und nicht – wie bei einer SPA – im Client ausgeführt. Im Browser läuft der clientseitige Teil des Frameworks, der die Kommunikation mit dem Backend steuert und zum Beispiel aktualisierte Seiteninhalte anfordert.

In Listing 3 ist die Rezeptliste mit Next.js implementiert:

Auch hier ist der Code wieder vereinfacht und verkürzt. Next.js basiert auf React und unterstützt die „React Server Components“, die auf dem Server ausgeführt werden (im Gegensatz zu „klassischen“ React-Komponenten, die auf dem Client ausgeführt werden).

Anders als in vielen anderen Frameworks wird in React nicht zwischen Model, View und Controller unterschieden. Hier übernimmt eine (Server-)Komponente diese Aufgaben. Im gezeigten Beispiel greift sie dazu auf eine Datenquelle zu (das kann z.B. eine Datenbank oder ein Microservice sein), um die Rezeptdaten sortiert und gefiltert zu lesen. Die gelesenen Daten sind gewissermaßen das Model, das im unteren Teil des Listings im „Template-Teil“ der Komponente verwendet wird, um die gewünschte Ausgabe zu erzeugen.

Genau wie in der Spring-Variante wird dieser Code bei einer Anfrage des Browsers ausgeführt, nur dass hier die Verzeichnisstruktur entscheidet, welcher „Controller“ aufgerufen wird und nicht eine Annotation an einer Methode. Zurückgeliefert wird von Next.js nun das fertig gerenderte HTML für die Seite, die der Browser unmittelbar darstellen kann. Auch das unterscheidet sich nicht von anderen serverseitigen Ansätzen.

Zusätzlich liefert Next.js allerdings auch eine proprietäre Beschreibung der Komponentenstruktur an den Client. Damit ist Next.js bei allen weiteren Interaktionen in der Lage, kleinteilige Updates vom Server anzufordern und in die bestehende Seite zu integrieren. Wird im Browser zum Beispiel auf einen der Buttons zum Neusortieren geklickt, wird erneut die Server Komponente aufgerufen.

Auch das ist ähnlich wie im Spring-Beispiel. Im Spring-Beispiel erfolgt beim Klicken auf den Link allerdings ein „regulärerer“ Server-Request, auf den hin Spring eine komplett neue Seite rendert, zurückliefert und der Browser die bestehende Seite austauscht – mitsamt der oben besprochenen Konsequenzen.

In der Next.js-Anwendung sind die „Buttons“ ebenfalls normale HTML a-Elemente. Und falls zur Laufzeit (noch) kein Next.js- bzw. JavaScript-Code vom Browser geladen wurde, verhalten sie sich auch wie normale a-Elemente. In diesem Fall ist das Verhalten identisch mit dem Spring-Verhalten. Ist das JavaScript aber im Browser vorhanden, unterbindet Next.js das Default-Verhalten und sendet selbst einen Request an den Browser. Der Request wird weiterhin in der schon bekannten Server-Komponente verarbeitet.

Das Ergebnis ist nun aber keine vollständig neue Seite im HTML-Format, sondern eine Art proprietäre Beschreibung der neuen Seite. Diese nimmt Next.js im Client entgegen und passt die bestehende Seite an, anstatt diese komplett zu ersetzen. Auf diese Weise bleibt zum Beispiel die Eingabe im Newsletter-Textfeld auch dann erhalten, wenn man die Rezeptliste neu sortiert. Serverseitig im Code ist davon jedoch nichts zu sehen – der Code kann immer so tun, als ob er eine komplette Seite erzeugen müsste. (Dieses Verhalten ist typisch für React).

Mit Next.js client- und serverseitig entwickeln

Für weitergehende Interaktionen können die React-Komponenten natürlich auch auf dem Client ausgeführt werden. Der Like-Button dient hierzu wieder als Beispiel. Diese Komponente kann – wie im serverseitigen Beispiel – als form-Element implementiert werden:

Allerdings wird hier als action keine URL eingetragen, die vom Browser aufgerufen wird, sondern eine Funktion, die die Logik implementiert, die ausgeführt werden soll, sobald das Formular abgesendet wird. In React werden solche Funktionen auch als „Server-Action“ bezeichnet, da sie vom Client aufgerufen und auf dem Server ausgeführt werden.

Um sie als serverseitige Funktion zu kennzeichnen, werden sie mit „use server“  markiert. Und auch hier arbeitet Next.js wieder mit Progressive Enhancement. Falls auf den Button geklickt wird und noch kein JavaScript geladen wurde, wird ein „normales“ Form Submit vom Browser durchgeführt. Ist der JavaScript-Code aber geladen und gestartet, übernimmt Next.js den Server-Aufruf und führt nur die Server-Action aus.

In der Implementierung der Action teilt diese Funktion Next.js dann mit, welche Teile der Anwendung durch die Ausführung ungültig geworden sind und neu gerendert werden müssen. Auch darum kümmert sich Next.js dann (durch Ausführung der entsprechenden React-Komponenten) und liefert die aktualisierte UI-Beschreibung zum Client zurück.

In dieser Form enthält der Like-Button noch keine Logik, die nur auf dem Client ausgeführt werden soll und selbst implementiert ist. Clientseitig hat Next.js bislang alle Aufgaben übernommen. Allerdings gibt es natürlich zahlreiche Anwendungsfälle, wo auch selbstgeschriebener Code auf dem Client ausgeführt werden soll. Zum Beispiel weil unmittelbar auf eine Eingabe reagiert werden soll (Validierung, Zeichenzähler). Oder weil nach dem Absenden des Formulars der Button deaktiviert sein soll, solange der Request noch läuft.

Auch clientseitiger Code wird in React-Komponenten implementiert. Allerdings müssen diese speziell markiert sein, damit Next.js weiß, dass deren JavaScript-Code auf dem Server ausgeführt werden soll. Das Build-Tool von Next schiebt dann beim Bauen diesen Code in das JavaScript-Bundel für den Browser. Die Markierung erfolgt mit der „use client“-Direktive.

In Listing 5 ist die neue Version des Like-Buttons zu sehen:

Diese verwendet die neue useActionState-Funktion von React, um beim Klicken die Server-Action auszuführen (aus technischen Gründen kann eine serverseitige Funktion nicht in derselben Datei wie eine Client-Komponente sein, deswegen ist der Code hier auf zwei Dateien aufgeteilt).

useActionState gibt der Komponente aber auch die Information, ob der Server Request gerade läuft und enthält zudem die Antwort des Server Requests (state). Mit dieser Information kann die Komponente den Button deaktivieren und nach dem Speichern die Darstellung aktualisieren und die neuen Likes anzeigen. Somit wird dann nur der Button an der Oberfläche aktualisiert.

Zur Verwendung wird die LikeButton-Komponente ganz regulär in den React-Komponentenbaum eingebunden – es gibt keine Spezialbehandlung. Die Komponente nimmt übrigens auch Properties entgegen. Das ist aus React-Sicht erstmal unspektakulär, allerdings wird die LikeButton-Komponente ja im Client ausgeführt. Die einbindende Komponente (RecipeCard) ist aber eine Server-Komponente, die ausschließlich auf dem Server ausgeführt wird.

Next.js sorgt beim Rendern der RecipeCard-Komponente automatisch dafür, dass die übergebenen Properties serialisiert, an den Client mitgeschickt und dort der LikeButton-Komponente zur Verfügung gestellt werden. Für den Verwender der Komponenten ist das vollständig transparent. Und übrigens wird auch diese Komponente bei einem Server Request auf dem Server in HTML vorgerrendert. Auch das ohne, dass man etwas dafür in der Anwendung tun müsste.

Fazit: Webanwendungen – klassisch oder Fullstack?

Insgesamt bietet Next.js eine interessante und konsequente Weiterentwicklung der klassischen serverseitigen Anwendungen. Man kann sagen, dass bei Next.js-Anwendungen JavaScript-Code im Browser nur schnipselweise zum Einsatz kommt – nämlich dort, wo es wirklich benötigt wird, um Interaktionen und feingranulare Aktualisierungen zu ermöglichen.

Darin unterscheidet es sich konzeptionell nicht von den klassischen serverseitigen Ansätzen. Allerdings sorgt Next.js automatisch dafür, dass der JavaScript-Code für den Client aus der Gesamt-Code-Basis extrahiert wird. Die Anwendung selbst wird „in einem Guß“ (in Form von React-Komponenten) mit einer Programmiersprache und einem Framework implementiert.

Gerade bei komplexen Webanwendungen lohnt sich daher eine genauere Betrachtung dieses (oder eines anderen Fullstack-)Frameworks. Sollte man bereits im Backend andere Technologien für die Geschäftslogik einsetzen (z.B. Spring Boot), muss das kein Ausschlusskriterium sein, denn in diesem Fall kann Next.js die Rolle eines „Backends for Frontends“ übernehmen. Die bestehenden Backend-Services werden dann z.B. per HTTP API angebunden.

Titelmotiv:

Photo by Christopher Gower on Unsplash

Nils Hartmann

Große Auswahl an günstigen Domain-Endungen – schon ab 0,08 € /Monat
Jetzt Domain-Check starten