Next.js, czyli React Server Side Rendering (cz.2)
W pierwszej części artykułu obszernie opisaliśmy działanie Next.js. Teraz przejdziemy do części praktycznej i pokażemy konkretne zastosowanie frameworka. Ten projekt może posłużyć Ci jako trening, gdzie krok po kroku będziesz konfigurować i rozbudowywać aplikację. Powstał on na użytek warsztatów wprowadzających do SSR za pomocą Next.js. W projekcie jest wykorzystane publiczne API, którego konfiguracja jest bardzo prosta – tak, aby całą uwagę poświęcić na poprawne ustawienie projektu. Całe UI jest zbudowane za pomocą Semantic UI React.
Mateusz Anioła. W Merixstudio pełni rolę nie tylko Senior Frontend Developera, ale także Team Leadera całego działu. Dzięki swojemu doświadczeniu i umiejętnościom wzniósł już niejeden projekt na wyższy level. Obecnie jego praca koncentruje się na zarządzaniu zespołem, projektowaniu procesów i przede wszystkim tworzeniu wysokiej jakości aplikacji webowych. Na przerwach w pracy, a także poza nią zawzięcie buduje potęgę swoich karcianych stworów grając w Magic: The Gathering.
Marcin Majewski. Pracuje jako Senior Frontend Developer w Merixstudio. Ma już na koncie ponad 6 lat doświadczenia w tworzeniu aplikacji webowych oraz gier. W swojej karierze zmagał się już z szeroką gamą różnych projektów z najróżniejszych branż. Po godzinach możecie spotkać go na korcie do squasha, z książką fantasy lub sci-fi albo oglądającego kolejny odcinek z serii Dragon Ball.
Najlepszym sposobem na zapoznanie się z projektem jest analiza commitów od samego początku – do sprawdzenia tutaj. Dzięki temu można zapoznać się ze sposobem budowania aplikacji od samego początku. Każdy commit dodaje małe lub iteruje na już istniejącej funkcjonalności.
Aplikacja jest podzielona na 3 widoki:
- Index – zawiera formularz z opcją wpisania wyszukiwanej frazy oraz ustawienia odpowiednich filtrów. Podczas wysyłania formularza należy wykonać przekierowanie na widok wyników wyszukiwania, gdzie wybrane parametry ustawia się jako query string w URL;
- Search – w getInitialProps pobierz z query string wyszukiwaną frazę oraz format do przeszukiwania. Następnie wykonuj akcję, która pobiera dane z API. W samym render wyświetl tabelkę z wynikami;
- Card – widok szczegółu pojedynczego wpisu. W getInitialProps trzeba pobrać id karty i wysłać o nią zapytanie do API. Następnie wyświetl detale karty.
Dodatkowo w aplikacji znajdują się:
- Layout – komponent, który służy do ustawienia odpowiedniego wyglądu dla każdej strony;
- Actions, reducers, store – cała konfiguracja Redux’a ustawiona tak, aby współgrała z Next.js. Dodatkowo każdy komponent w pages jest opakowany w HOC withRedux z paczki next-redux-wrapper.
Jak poprawnie zaimplementować uwierzytelnianie użytkownika w Next.js?
Każda większa aplikacja internetowa potrzebuje odpowiedniego systemu do uwierzytelniania użytkowników oraz sprawdzania czy posiada on uprawnienia do przeglądania konkretnego widoku.
W przypadku aplikacji React, która nie wykorzystuje SSR, typowym podejściem jest napisanie Higher Order Component lub innej funkcji pomocniczej. Funkcja ta sprawdza czy istnieje zapisany odpowiedni token (np. w cookies lub localStorage), który następnie należy dodać w nagłówku każdego zapytania. Jeśli takiego tokena nie ma i następuje próba odwiedzenia strony tylko dla zalogowanych użytkowników, to automatycznie wykonywane jest przekierowanie na stronę logowania.
Jeśli token istnieje, ale jest nieaktualny lub użytkownik nie ma odpowiednich uprawnień, to wystarczy, że sprawdzony zostanie status odpowiedzi z API – należy też odpowiednio zareagować.
W przypadku korzystania z SSR i Next.js sprawa się trochę komplikuje, ponieważ przeglądarka wysyła pierwsze zapytanie do serwera Next.js, a każde kolejne bezpośrednio do API. Pojawia się tutaj kilka problemów, które należy rozwiązać:
1. W getInitialProps trzeba sprawdzić czy kod jest wykonywany przez przeglądarkę, czy przez serwer (napisany kod musi być uniwersalny).
2. Jeśli zapytanie jest obsługiwane przez serwer, potrzeba w jakiś sposób dotrzeć do tokena użytkownika.
3. Wykonując zapytanie do API z serwera należy pamiętać o obsłudze błędów i dokonać odpowiednich akcji w zależności od kodu statusu odpowiedzi.
Na potrzeby tego artykułu zakładamy, że korzystamy z JWT oraz, że użytkownik jest już zalogowany i posiada odpowiedni token zapisany w cookies. Dodatkowo cała aplikacja będzie korzystać z Redux’a.
Pierwszym krokiem do obsługi uwierzytelniania użytkownika jest napisanie komponentu wyższego rzędu, w którym należy sprawdzać czy odpowiednie warunki zostały spełnione i czy można użytkownikowi bez przeszkód daną stronę pokazać. Wraz z wersją 6 Next.js pojawiła się możliwość nadpisania domyślnego komponentu App. Jest to świetne miejsce, aby taką logikę zawrzeć.
Zaprezentujemy tutaj podejście z wykorzystaniem HOC, które może być bardzo łatwo przeniesione do App. Podczas definiowania komponentów widoków w pages chcielibyśmy móc zdefiniować je w ten sposób:
import React from 'react'; import BasePage from '@shared/base/BasePage'; import Profile from '@routes/Profile/containers/Profile'; class ProfilePage extends React.Component { render() { return <Profile />; } } export default BasePage(ProfilePage, 'Private');
Z powyższego kodu wynika, że intencją jest napisanie HOC, który oprócz samego komponentu, jako kolejny argument ma przekazać dla kogo ma być dostępna dana strona. Taki HOC opakuje również komponent Profile w odpowiednią strukturę strony. Stwórz zatem podstawową strukturę BasePage:
export default (ChildComponent, permission = 'Public') => class extends React.Component { static async getInitialProps(context) { const { req, res, store } = context; let initProps = { isServer: !!req, isLoggedIn: (!!req && !!req.cookies.token) || store.getState().auth.me.id, }; return initProps; } render() { return ( <Layout> <ChildComponent {...this.props} /> </Layout> ); } };
Kod ten niedługo rozszerzymy o właściwą logikę jednak przeanalizujmy go najpierw linijka po linijce. Najpierw zdefiniowana została funkcja, która jako pierwszy parametr przyjmuje komponent, a jako drugi dostępność witryny. Domyślnie dostępność ustawiona jest jako Public. W takim przypadku strony, którym ta wartość nie została przekazana będą dostępne dla każdego odwiedzającego. Wspomniana funkcja zwraca komponent react’owy. Należy pamiętać, że Higher Order Component to nic innego jak wariacja Higher Order Function, czyli takiej, która zwraca lub przyjmuje funkcję jako argument, a przecież komponenty react’owe to nic innego jak funkcje właśnie. W tym wypadku zwrócona zostanie nienazwana klasa (klasy to też funkcje!), która rozszerza React Component.
Zgodnie z wymaganiami Next.js, jeśli chcesz wykonywać asynchroniczne operacje musisz zdefiniować statyczną metodę getInitialProps – tak jak w przypadku każdego widoku znajdującego się w folderze pages. W takiej funkcji definiujemy nasze początkowe własności (initProps).
W którym momencie użytkownik jest zalogowany?
- Jeśli jest to pierwsze zapytanie i jest obsługiwane przez Node, to należy sprawdzić czy w ciasteczkach wysłanego zapytania znajduje się token (token JWT zapisujemy w cookies pod nazwą token). Pamiętaj, że aby móc odnieść się do ciasteczek w zapytaniu w ten sposób trzeba rozszerzyć server.js i dodać tam cookie parser lub manualnie przeformatować ciąg znaków na interaktywny obiekt.
- Jeśli jest to zapytanie asynchroniczne wysłane przez przeglądarkę, to sprawdź, czy istnieje obiekt użytkownika w store (zakładając, że używasz Reduxa) lub jesteś w stanie zweryfikować w inny sposób czy użytkownik jest zalogowany.
Teraz, kiedy wszystko jest przygotowane, można zacząć rozważać różne przypadki dostępności strony oraz stanu użytkownika. Sprawdźmy pierwszy przypadek – strona jest dostępna tylko dla zalogowanych użytkowników:
if (permission === 'Private' && !initProps.isLoggedIn) { res.writeHead(302, { Location: '/login' }); res.end(); res.finished = true; }
Jeśli strona powinna być dostępna tylko dla zalogowanych użytkowników, a zgodnie z powyższymi warunkami nie ma możliwości stwierdzenia tego, to należy wykonać przekierowanie na stronę logowania. Następnie wywołujemy funkcję zakończenia odpowiedzi. Dlaczego wykonywać res.end oraz ustawiać res.finished? Jest to zgodne z przykładem zawartym w wiki Next.js, chociaż z drugiej strony dokumentacja Node mówi, że finished będzie ustawione na true w momencie zakończenia wykonywania funkcji end. Własność ta jest słabo udokumentowana, jednak w tym wypadku postąpiliśmy zgodnie z przykładem dostarczonym przez Next.js.
Dodatkowo rozważmy przypadek, kiedy strona powinna być dostępna dla niezalogowanych użytkowników (np. logowanie czy rejestracja).
if (permission === 'GuestOnly' && initProps.isLoggedIn) { res.writeHead(302, { Location: '/home' }); res.end(); res.finished = true; }
Działanie jest analogiczne jak w przypadku strony Private. Następnie, jeśli użytkownik spełnia podane warunki, należy rozważyć przypadek, w którym jest to pierwsze zapytanie (kod wykonuje Node) oraz użytkownik jest zalogowany. Oznacza to, że istnieje token lecz użytkownik jeszcze nie został uwierzytelniony, a poprawność tokenu nie została sprawdzona.
if (initProps.isServer && initProps.isLoggedIn) { request.setAuthToken(req.cookies.token); try { await store.dispatch(getMe()); } catch (error) { res.writeHead(302, { Location: '/logout' }); res.end(); res.finished = true; } }
Najpierw dodaj token jako nagłówek do zapytań (warto skorzystać z wcześniej napisanej funkcji pomocniczej setAuthToken) zgodnie z wymogami JWT. Kolejnym krokiem jest wykonanie akcji redux’owej, która jest odpowiedzialna za wykonanie odpowiedniego zapytania do API i – w przypadku sukcesu – zapisania danych użytkownika w store pod auth.me (dlatego wcześniej trzeba było sprawdzić czy użytkownik jest zalogowany poprzez store.getState().auth.me.id).
Jeśli z jakiegoś powodu wystąpił błąd, podczas próby pobrania danych użytkownika, może to oznaczać, że token wygasł lub jest niepoprawny. W takim wypadku należy wykonać przekierowanie na stronę logout, która powinna usunąć token z cookies i dodany header do każdego zapytania oraz przekierować użytkownika na stronę do logowania.
Na samym końcu, po spełnieniu tych wszystkich warunków i upewnieniu się, że użytkownik może daną stronę odwiedzić wykonaj getInitialProps komponentu strony (ChildComponent).
if (typeof ChildComponent.getInitialProps === 'function') { initProps = { ...initProps, ...await ChildComponent.getInitialProps(context), }; }
Powyższy kod jest bardzo podobny do tego zastosowanego w miejscu nadpisywania App. Podsumowując, cały kod HOC powinien wyglądać następująco:
import React from 'react'; import request from '@utils/request'; import { getMe } from '@store/actions/auth'; import Layout from '@shared/layout/Layout'; export default (ChildComponent, permission = 'Public') => class extends React.Component { static async getInitialProps(context) { const { req, res, store } = context; let initProps = { isServer: !!req, isLoggedIn: (!!req && !!req.cookies.token) || store.getState().auth.me.id, statusCode: 200, }; if (permission === 'Private' && !initProps.isLoggedIn) { res.writeHead(302, { Location: '/login' }); res.end(); res.finished = true; } if (permission === 'GuestOnly' && initProps.isLoggedIn) { res.writeHead(302, { Location: '/home' }); res.end(); res.finished = true; } if (initProps.isServer && initProps.isLoggedIn) { request.setAuthToken(req.cookies.token); try { await store.dispatch(getMe()); } catch (error) { res.writeHead(302, { Location: '/logout' }); res.end(); res.finished = true; } } if (typeof ChildComponent.getInitialProps === 'function') { initProps = { ...initProps, ...await ChildComponent.getInitialProps(context), }; } return initProps; } render() { return ( <Layout statusCode={this.props.statusCode} > <ChildComponent {...this.props} /> </Layout> ); } };
Powyższy przykład bardzo dobrze obrazuje, problemy można napotkać implementując SSR za pomocą Next.js oraz pokazuje przykładowe rozwiązanie jednej z najczęściej implementowanych funkcjonalności w każdej aplikacji. Koniecznie pamiętaj, aby kod, który piszesz był uniwersalny (izomorficzny)!
Podsumowanie
Tworzenie aplikacji webowych może być momentami przytłaczające – na rynku dostępnych jest wiele rozwiązań i coraz częściej znaczną część logiki biznesowej implementuje się po frontowej stronie aplikacji. Poznanie wszystkich składowych oraz dodatkowych bibliotek potrzebnych, aby zbudować pełnoprawną aplikację zajmuje sporo czasu i może niepotrzebnie skomplikować architekturę projektu.
SSR może być jednym z czynników i składowych takich właśnie problemów. Jeśli jednak decyzję o wyborze odpowiedniego rozwiązania podejmiemy odpowiednio szybko, implementacja renderowania po stronie serwera może okazać się łatwa i przyjemna.
W swoich projektach również zaczęliśmy używać Next.js, co automatycznie przyspieszyło pracę nad SSR. Wcześniej nad wszelkimi rozwiązaniami, które daje nam framework musieliśmy pracować sami. W sytuacji kiedy klient chciał daną technologię stosować w dalszym etapie tworzenia aplikacji, często okazywało się to być bardziej czasochłonne właśnie z powodu użytych wcześniej rozwiązań – należy wtedy całą aplikację sprawdzić jeszcze raz, aby uniknąć błędów. Next.js daje możliwość zbudowania strony od razu pod SSR przez co jakikolwiek problem jest natychmiast widoczny i nie powiela się niepoprawnego rozwiązania przy kolejnych podstronach.
Next.js, pomimo że jest minimalistycznym frameworkiem, jest bardzo elastyczny i rozszerzalny. Uruchomienie aplikacji React wraz z Next.js jest szybkie, a konfiguracja przejrzysta i dobrze udokumentowana. Sam framework jest wciąż intensywnie rozwijany i wspierany – kilka z funkcjonalności, które opisaliśmy wyżej zostało wprowadzone dopiero w najnowszej wersji 6.0 (chociażby możliwość nadpisania App.js). I co najważniejsze, idealnie sprawdza się w przypadku SSR.