Vollwertiger Full-Stack mit Next.js

12.01.2022 Tom Trapp
Mobile nextjs javascript frontend framework handson howto

In diesem TechUp wollen wir NextJS kennenlernen, die Konzepte und Möglichkeiten welches es bietet ausprobieren und eine Übersicht der unterschiedlichen Deployment-Möglichkeiten geben.

Next, Nest, Nuxt... Grundsätzlich gibt es verschiedenste Frameworks, welche auf NodeJS aufbauen und JavaScript, TypeScript oder eine andere Frontend-Sprache ins Backend bringen wollen.

Hier eine kurze Übersicht, um Verwirrungen zu vermeiden.

  • NestJs - server side applications, Typescript, APIs
  • NuxtJs - Plugin für VueJS, Developer Experience, sustainable development
  • NextJs - The React Framework For Production React, SEO optimized, SSR für React

NextJs

Was ist NextJS?

NextJS ist eine Erweiterung für React, welche nach eigenen Aussagen die beste Developer-Experience mit den Features, welche man für Produktion benötigt, bietet. Technisch gesehen baut NextJS auf Node.js auf und erweitert React so um Server-Side Rendering und Static Side Generation. Hinter NextJS steht die Firma Vercel, der erste Release wurde 2016 veröffentlicht.

Nachfolgende lernen wir einige zentrale Funktionen von NextJS kennen, das Framework bietet aber weit mehr Funktionalität wie ein E-Commerce Starter Pack, Code Splitting und weitere nützliche Features.

Welches Problem löst es?

Einfach gesagt löst Next.js wiederkehrende SEO Probleme in React-Applikationen, indem es Server Side Rendering einsetzt. Grundsätzlich nutzt ein React Projekt (und auch andere FE-Frameworks) einen Client Side Rendering (CSR) Ansatz (unten mehr).

SEO technisch kann dies zu zwei Problemen führen:

  • Content kann nicht immer korrekt von SEO-Bots indexiert werden, da Bots JavaScript interpretieren und ausführen können müssen
  • Der First Contentful Paint ist langsam, da erst alles im Client zusammengebaut werden muss

Um diese Probleme zu beseitigen, gibt es generell im Web unterschiedliche Rendering-Strategies. Drei davon unterstützt NextJS out-of-the-box. Diese Konzepte sind grundsätzlich NextJS oder React unabhängig und sind auch in anderen Technologien zu finden.

In unserem Beispiel wollen wir ToDos von einem Restendpunkt abfragen und darstellen, ein simpler Use-Case. Die Code-Beispiele sind wie immer auf GitHub zu finden.

Rendering Strategies

CSR - Client Side Rendering

Als erste Rendering Strategy wollen wir uns das Client Side Rendering genauer anschauen. Hier sei erwähnt, dass NextJS diesen Ansatz nicht out of the box unterstützt, der Vollständigkeit halber wollen wir aber trotzdem kurz darauf eingehen.

CSR - Client Side Rendering

Beim CSR bekommt der Client ein Wrapper-HTML vom Browser, darin befindet sich noch kein Inhalt. Über dieses HTML werden dann JavaScript-Files wie zum Beispiel ein Client Side JavaScript Framework wie React geladen. React fragt dann alle dynamischen Daten ab, baut den Content zusammen, rendert die Page und macht die Seite interaktiv. Diesen Ansatz findet man bei den meisten Frontend-Frameworks.

Deployment

Das Deployment dieser Art ist recht simple, da keine Computing Power benötigt wird. Klassischerweise hat man einen Ordner mit statischen HTML, CSS & JS Files und muss dies hosten.

Bekannte Möglichkeiten dies zu deployen:

  • Amplify
  • Digital Ocean App 'Static Site'
  • Statisches Webhosting oder CDN (z. B. AWS S3 Bucket)

SSG - Static Site Generation

SSG - Static Site Generation

Unter SSG versteht man die komplette Generierung aller Resource & Pages zur Build Zeit. Zur Laufzeit werden nur noch HTML Files von einem Webserver oder einem CDN geladen.

Dies bedeutet, dass bei jeder Änderung am Inhalt der Seite das komplette Projekt erneut gebaut werden muss.

Hier hört man auch oft den Begriff Static Site Generator oder JamStack, mehr dazu in unserem TechUp So geht Headless-CMS mit JAMstack.

Technisch gesehen wird zur Build-Zeit der Server gestartet und alle bekannten Routes werden aufgerufen und deren HTML wird extrahiert. Dies bedeutet auch, dass zur Build-Zeit alle Umsysteme abgefragt werden.

Gibt es auf einer Page weitere URLs zu z. B. Detailseiten werden diese ebenfalls aufgerufen. So werden bei einem Online-Shop alle verlinkten Produktdetailseiten ebenfalls abgefragt und statisch extrahiert. Dies kann, je nach Grösse des Projektes zu langen Build-Zeiten führen.

Selbstverständlich hat man hier weiterhin die Möglichkeit, custom Javascript oder gar ein Framework zur Laufzeit zu laden und auf Benutzer Interaktionen reagieren zu können. Dieses Laden von Frameworks zur Laufzeit 'on-top' nennt man Hydration.

Deployment

Das Deployment dieser Art ist ebenfalls recht simple, da keine Computing Power benötigt wird. Klassischerweise hat man einen Ordner mit statischen HTML, CSS & JS Files und muss dies hosten. Die Möglichkeiten sind identisch wie die beim CSR Ansatz.

NextJS Implementation

Branch: ssg

NextJS bietet diese Funktionalität out-of-the-box an.

Mit der Methode getStaticProps sagt man NextJS, dass es sich um eine SSG-Page handelt und die Daten zur Build-Zeit abgerufen werden sollen.

// This function gets called at build time
export async function getStaticProps() {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const todos = await res.json()
    return {
        props: {
            todos,
        },
    }
}

Hierfür muss in der package.json der Build-Befehl angepasst werden:

next build && next export

Mit export sagt man der Next.js CLI, dass alle Pages als Static-Sites ausgegeben werden sollen und in den Order out/ gelegt werden.

Anschliessend können wir dann via npm run build unser SSG-Projekt bauen. Glücklicherweise zeigt uns NextJS im Build genau an, um welche dieser Arten es sich handelt.

npm run build

> build
> next build && next export

info  - Checking validity of types  
info  - Creating an optimized production build  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (5/5)
info  - Finalizing page optimization  

Page                                       Size     First Load JS
┌ ○ /                                      239 B          71.7 kB
├   /_app                                  0 B            71.5 kB
├ ○ /404                                   194 B          71.7 kB
├ λ /api/hello                             0 B            71.5 kB
├ ● /ssg                                   307 B          71.8 kB
└ ○ /static                                3.29 kB        74.8 kB
+ First Load JS shared by all              71.5 kB
  ├ chunks/framework-6e4ba497ae0c8a3f.js   42 kB
  ├ chunks/main-deb592798b94b511.js        28.2 kB
  ├ chunks/pages/_app-9cd1d19dd7237c4c.js  493 B
  ├ chunks/webpack-514908bffb652963.js     770 B
  └ css/2974ebc8daa97b04.css               209 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

...

Um unser Ergebnis zu testen, müssen wir nach dem Build & Export einen lokalen HTTP-Server starten, damit die Dateien aufrufbar sind.

npm install http-server -g
http-server -p 8080 -c-1 out/ -o

Anschliessend können wir die URL http://127.0.0.1:8080/ssg aufrufen und sehen im Quelltext, dass das fertige HTML mit allen Inhalten des Restendpunkts vom HTTP-Server zurückkommt. Hier wurden die Daten des API-Endpunktes zur Build-Zeit abgerufen.

Wichtig hier ist, dass diese Art nicht mit anderen Arten vermischt werden kann (beispielsweise mit ssr), da es sonst beim next export zu Buildfehlern kommt. Dies bedeutet, dass alle Seiten des kompletten Projekts im SSG-Ansatz zur Build-Zeit generiert werden müssen.

Dieser Ansatz bietet einen grossen Vorteil, wenn sich der eigentlich Content nicht oft ändert. Beispielsweise könnte so super einen Blog aufbauen und veröffentlichen. Selbstverständlich hat man auch hier die Möglichkeit, mittels des Prozesses der Hydration zur Laufzeit ein Frontend-Framework zu laden und nur einzelne Daten (z. B. den aktuellen Preis eines Produkts) zu laden.

SSR - Server side rendering

SSR - Server side rendering

Unter SSR versteht man genau das Gegenteil von SSG, die Pages werden zur Request-Time auf dem Server generiert. Dies bedeutet, dass der Client die fertig gerenderte HTML-Seite mit allen Inhalten vom Server bekommt. Diesen Ansatz kennen wir bereits aus der Backend- bzw. MVC-Entwicklung.

Hier wird die Last auf Client-Seite auf die Server-Seite verschoben, bei jedem Request werden Umsysteme erneut abgefragt, um die HTML-Seite zusammenzubauen. Dieser Ansatz kommt speziell bei sehr flüchtigem Content mit vielen beweglichen Teilen, welche gar pro User unterschiedlich sind, zum Einsatz.

Deployment

Wichtig ist hier zu beachten, dass man Rechenleistung benötigt, da man einen laufenden Server, welcher das Zusammenbauen der HTML-Seite übernimmt, braucht.

Bekannte Möglichkeiten dies zu deployen:

  • Amplify in Kombination mit CloudFront & Lambda@Edge
  • Digital Ocean App 'Web Service'
  • Netlify
  • Node JS Server
  • Docker Image

Grundsätzlich braucht man für diese Art des Deployments entweder einen Hosting-Provider, welcher NodeJS direkt oder aber welcher Docker-Container unterstützt.

NextJS Implementation

Branch: ssr

In NextJS ist diese Art und Weise per default aktiviert. Ähnlich wie beim SSG Ansatz bietet NextJS hier auch eine Methode an, um dieses Verhalten zu steuern. Die Methode getServerSideProps wird somit zur Request-Time aufgerufen.

// This function gets called at request time
export async function getServerSideProps() {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const todos = await res.json()
    return {
        props: {
            todos,
        },
    }
}

Hier ist schön zu sehen, dass die eigentliche Implementation identisch zum SSG-Ansatz ist, es ändert sich lediglich der Methodenname. Beim Builden benötigt man hier kein spezielles Target, man kann via next dev sofort einen Dev-Server starten. Will man das Artefakt bauen und anschliessend mit dem produktiven Profile starten nutzt man next build && next start.

Dies können wir via npm wie folgt machen:

npm run build
npm run start

Anschliessend können wir via http://localhost:3000/ssr unsere SSR-Seite aufrufen, welche bei jeden Request erneut zusammengebaut wird. Da wird kein Caching implementiert haben wir hier auch bei jedem Request der API-Endpunkt vom Server aus abgefragt.

Schauen wir uns nochmal den Build-Output genauer an, fällt eine Besonderheit schnell auf:

Page                                       Size     First Load JS
┌ ○ /                                      239 B          71.7 kB
├   /_app                                  0 B            71.5 kB
├ ○ /404                                   194 B          71.7 kB
├ λ /api/hello                             0 B            71.5 kB
├ ● /ssg                                   307 B          71.8 kB
├ λ /ssr                                   306 B          71.8 kB
└ ○ /static                                3.29 kB        74.8 kB
+ First Load JS shared by all              71.5 kB
  ├ chunks/framework-6e4ba497ae0c8a3f.js   42 kB
  ├ chunks/main-deb592798b94b511.js        28.2 kB
  ├ chunks/pages/_app-9cd1d19dd7237c4c.js  493 B
  ├ chunks/webpack-514908bffb652963.js     770 B
  └ css/2974ebc8daa97b04.css               209 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

Wir haben weiterhin unsere /ssg Page im Projekt, der Build war erfolgreich. Dies ist einer der grossen Vorteile von NextJS, SSR & SSG lassen sich vermischen (aber nicht via next export). Zur Build-Zeit wurde nun unsere SSG-Seite aufgerufen und gebaut und im Ordner .next/server/pages im Projekt abgelegt. Dies erlaubt es uns, teile unserer Applikation statisch zur Build-Zeit und andere Seiten dynamisch zur Request-Time zusammenzubauen.

Selbstverständlich kann auch hier via Hydration mittels JavaScript o.ä. auf Benutzerinteraktionen reagiert werden.

ISR - Incremental Static Regeneration

ISR - Incremental Static Regeneration

Sobald das Projekt grösser und grösser wird, haben beide vorherigen Arten ihre Nachteile und Tücken. Hat der Online-Shop beispielsweise 200'000 Produkte und ein Produkt benötigt ca. 50 MS Build-Zeit so läuft der Build mehr als 2 Stunden. Je nach Masse der User könnte es bei einem SSR Ansatz zu Last und Performance-Problemen kommen.

Grundsätzlich könnte hier mit einem custom implementieren Caching eine Verbesserung erzielt werden, es gibt aber auch eine bessere Möglichkeit!

Incremental Static Regeneration oder kurz ISR erlaubt es, statisch gebaute Seiten zur Laufzeit zu aktualisieren. Wir können so genau steuern, welche Pages wann aktualisiert werden sollen.

Deployment

Die Voraussetzungen für ein Deployment hier sind dieselben wie beim SSR Ansatz, da hier ebenfalls Rechenleistung benötigt wird.

NextJS Implementation

Revalidate

Branch: isr

ISR aktivieren wir, indem wir in die getStaticProps Methode die revalidate Property packen.

Hier sagen wir, nach mehr als 10 Sekunden soll die Page erneut generiert werden. Mittels dem Timestamp sehen wir zur Laufzeit, dass die Page nach den initialen 10 Sekunden aktualisiert wird. Davor sehen wir die SSG-Version der Page, welche zur Build-Zeit gebaut wurde.

// This function gets called at build and request time
export async function getStaticProps() {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos')
    const todos = await res.json()
    const now = Date.now()
    return {
        props: {
            todos,
            now,
        },
        revalidate: 10
    }
}

Auch hier ist wieder schön zu sehen, dass nur ein Property hinzukommt und die eigentliche Implementation gleich bleibt.

Nach diesen 10 Sekunden wird der Cache aktualisiert und alle Benutzer bekommen für mindestens die nächsten 10 Sekunden dieselbe, aktualisierte Version aus dem Cache geliefert. Sobald ein User die Seite dann erneut aufruft wird die Seite einmalig server-side erneut gerendert, das Resultat wird wieder in den Cache gelegt.

Um dies zu prüfen, müssen wir unseren Server mit dem produktiven Profil starten, daher nutzen wir wie bei SSR auch folgende Kommandos:

npm run build
npm run start

Der Build-Output zeigt unter anderem ISR korrekt an:

Page                                       Size     First Load JS
├ ● /isr (ISR: 10 Seconds)                 339 B          71.8 kB

...

   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

Unter http://localhost:3000/isr können wir die Seite dann wie gewohnt aufrufen, wir sehen, wir bekommen wie beim SSR das komplette HTML vom Server zurück. Bei einem Refresh nach einiger Zeit sehen wir dann, dass sich der Timestamp ändert, die Seite wurde erneut generiert. Laden wir dann sofort die Seite erneut (innerhalb von 10 Sekunden) bleibt die Anzeige dieselbe, wir haben die neue, gecachte Version.

Nun haben wir gelernt, wie wir statische Seiten nach einer bestimmten Zeit wieder aktualisieren können.

GetStaticRoutes

Der Build bei 200'000 Produkte würde aber immer noch sehr lange gehen, aber auch hier bietet NextJS eine clevere Lösung.

Mittels der Methode getStaticPaths können wir steuern, welche Seiten zur Build-Zeit gebaut werden soll. In Kombination mit revalidate können wir so genau steuern, welche Pages zur Build-Zeit gebaut und welche zur Request-Zeit für wie lange gecacht werden sollen. Dies erlaubt es uns, mit der Build-Zeit und Cache-Hit-Rate etwas zu spielen.

// pages/products/[id].js

export async function getStaticPaths() {
    const products = await getTop1000Products();
    const paths = products.map((product) => ({
        params: { id: product.id },
    }));

    return { paths, fallback: 'blocking' };
}

Beispiel aus https://vercel.com/docs/concepts/next.js/incremental-static-regeneration

Im oberen Code-Ausschnitt befinden wir uns in einer parametrisierbaren Route, welche über den ID-Parameter gesteuert wird. Fachlich gesehen handelt es sich hierbei um die Produktdetailseite, welche anhand der ID zusammengebaut wird.

Wir rufen hier die 1000 beliebtesten Produkte auf und lassen diese via SSG statisch bauen, die restlichen Produkte werden via SSR & ISR zur Request-Time gebaut und gecacht. Dies beschleunigt den Build enorm, trotzdem nutzen wir alle ISR Vorteile.

Mittels fallback: blocking sagt man, dass für den ersten User, welcher eine Produkt ausserhalb der 'Top 1000' aufruft, die Seite server-side gerendert und dann in den Cache gelegt wird. Alternativ könnte man dem Benutzer eine Waiting-Page anzeigen lassen, die Page wird dann nach erfolgreichen generieren automatisch nachgeladen.

Deployment Docker

Selbstverständlich lässt sich eine NextJS Applikation auch containerisieren und so via Docker, z.B. in ein Kubernetes-Cluster deployen. In unserem Beispiel ist das Dockerfile auf dem Branch ssr zu finden, dies kann mit folgenden Commands gebaut und gestartet werden:

docker build . -t my-next-js-app:1.0.0
docker run -p 3000:3000 my-next-js-app:1.0.0

Anschliessend ist unsere Application wie gewohnt unter localhost:3000 aufrufbar.

Dockerfile ist inspiriert von https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

Fazit

NextJS verspricht mit ihrem Slogan The React Framework for Production Grosses! In der Tat bietet NextJS im Vergleich zu einer normalen React-Anwendung zahlreiche Vorteile, welche speziell in Produktion zum Tragen kommen.

Wie bei vielen Frameworks und Technologien muss man sich vorher klar über seine Use-Cases und das Einsatzgebiet sein. Neben den frontend-facing Funktionen bietet NextJS ja auch eine bereite Palette an Möglichkeiten wie zum Beispiel das Verbinden mit einer Datenbank. Hier muss man sicherlich, anhand der Grösse und Komplexität der Anwendung entscheiden, ob ein Einsatz einer anderen Technologie, z.B. Spring Boot mit Hibernate, nicht passender ist.

Nichtsdestotrotz bietet NextJS für kleinere bis mittlere Webanwendung und speziell interaktive Blogs eine sehr gute Developer-Experience mit zahlreichen modernen Funktionen, welche sich positiv auf das User-Erlebnis auswirken.

Die Beispiele sind wie immer auf GitHub zu finden.

Stay tuned! 🚀

Tom Trapp – Problemlöser, Innovator, Sportler. Am liebsten feilt Tom den ganzen Tag an der moderner Software und legt viel Wert auf objektiv sauberen, leanen Code.