

1/1/1970
Jak wdrożyłem SSG w 2 dni
Cześć! Jeszcze rok temu myślałem, że to niemożliwe. A właśnie skończyłem wdrażać Static Site Generation (SSG) dla Foony w 2 dni i jestem z tego naprawdę zadowolony. To nie był też mój pierwszy raz, kiedy próbowałem rozgryźć SSG dla Foony. Patrzyłem wcześniej na NextJS, Vike, Astro, Gatsby i kilka innych rozwiązań. Miałem nawet falstart z NextJS, ale rozbiłem się o złożoność SPA Foony i tysiące plików. Migracja byłaby koszmarem i zajęłaby całe miesiące. Do tego dorzuciłaby jeszcze więcej złożoności wszystkim innym osobom pracującym nad stroną, bo musiałyby uczyć się NextJS i jego wszystkich dziwactw.
Chciałem czegoś lekkiego i łatwego do wdrożenia. Czegoś, co pozwoli nam dalej pisać kod tak jak do tej pory, bez zastanawiania się nad SSG (z wyjątkiem useMediaQuery - tutaj naprawdę nie ma dobrego obejścia). Poniżej opisuję, dlaczego postawiłem na autorskie rozwiązanie, jakie dokładnie problemy napotkałem (szczególnie z granicami Suspense w Reakcie) i jak je rozwiązałem.
Dlaczego nie standardowe rozwiązania?
Kiedy pierwszy raz zacząłem myśleć o dodaniu SSG do Foony, naturalnie spojrzałem na NextJS (branżowy standard), Vike i Astro.
NextJS: za duża migracja
NextJS jest potężny, ale wymagałby ogromnej migracji istniejącego SPA Foony opartego na Reakcie. Mamy tysiące plików, skomplikowaną logikę routingu i sporo własnej infrastruktury. Migracja do NextJS oznaczałaby:
- przepisanie całego naszego systemu routingu
- przebudowę tego, jak ładujemy gry i komponenty
- miesiące pracy tylko po to, żeby wrócić do tego samego zestawu funkcji
- potencjalne zmiany wywracające wszystko użytkownikom
- zmianę sposobu, w jaki obsługujemy obrazki
- znacznie wolniejsze buildy (potencjalnie 5-30 minut. Nie mam na to twardych liczb poza tą 5-letnią dyskusją na GitHubie)
- cały zespół uczący się nowego narzędzia (NextJS) i wolniejsze tempo pracy na zawsze
- migrację kodu za każdym razem, gdy NextJS postanowi wprowadzić zmiany niezgodne wstecz
Nawet zacząłem ten falstart z NextJS, ale szybko dotarło do mnie, że koszt migracji jest zbyt wysoki. Ta cała złożoność zupełnie się nie opłacała.
Vike: podobna złożoność
Vike (dawniej vite-plugin-ssr) miał podobne problemy. Jest co prawda bardziej elastyczny niż NextJS, ale wciąż wymagałby solidnego przemeblowania naszego kodu. Krzywa uczenia i wysiłek związany z migracją zupełnie nie równoważyłyby zysków.
Astro: nie ta architektura
Astro jest świetne dla serwisów mocno nastawionych na treści, ale Foony to złożona platforma gier multiplayer. Potrzebujemy aktualizacji w czasie rzeczywistym, połączeń WebSocket i dynamicznych komponentów Reacta. Architektura Astro po prostu nie pasuje do tego, co budujemy.
Rozwiązanie: autorskie SSG
Po tym, jak kilka dni wcześniej, po i18n, odważyłem się na mój „fałszywy SSG”, zdecydowałem się na małe, lekkie, autorskie rozwiązanie dla SSG w Foony.
Mój „fałszywy SSG” polegał na wyciąganiu treści wpisu z podstron z blogiem (routy
/postsi strony gier) i wstawianiu ich dokładnie tam, gdzie wyrenderowałby je klient, specjalnie po to, żeby wyszukiwarki i LLM-y lepiej rozumiały Foony. Do tego dokładane było też schema ld+json i trochę drobnych rzeczy pod SEO.
Podejście jest proste:
- Zbudować to na istniejącym SPA w Reakcie: żadnej migracji, po prostu dokładamy generowanie SSG w czasie builda.
- Użyć
renderToReadableStream: strumieniowe API SSR z Reacta 18 obsługuje Suspense natywnie. - Generować statyczne pliki HTML: prerenderować routy podczas builda i serwować je jako statyczne pliki, korzystając z naszego SitemapGeneratora, żeby dostać listę tras.
- Minimalne zmiany w istniejącej bazie kodu: większość komponentów działa dokładnie tak jak wcześniej.
Główna implementacja mieszka w client/src/generators/GenerateShellSsgFromSitemap.ts. Czyta sitemapę, renderuje każdy rout przy użyciu renderToReadableStream z Reacta i zapisuje HTML do statycznych plików. Prosto, dokładnie tak jak lubię!
Przy okazji wyszło to całkiem szybkie. Około 2800 routów wyrenderowanych w 10 sekund. Miło. To znacząco szybciej niż NextJS, Gatsby i Astro. <img alt="SSG console log showing time taken" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
Mógłbym długo rozprawiać o prostocie. Nawet jeśli nie zapewni ci awansu w wielkich korporacjach z powodu „braku złożoności”, prosty kod jest piękny, łatwy w utrzymaniu i ogólnie dużo lepszy dla prędkości pracy programistów. To jedna z rzeczy, które naprawdę podziwiam w zasadach Zen.
Problem z granicami Suspense
No i mam SSG, treść pojawia się w HTML... ale moje strony są puste! Jak to możliwe?! <img alt="SSG blank page" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Okazało się, że renderToReadableStream nadal zostawia granice Suspense, nawet jeśli zrobisz await stream.allReady. Podejrzewam, że to dlatego, że to w końcu „stream” i jest zaprojektowany tak, żeby przekazywać dane klientowi w miarę, jak spływają bajty.
Co wyrzuca React
Gdy użyjesz renderToReadableStream razem z Suspense, React zwraca mniej więcej taki HTML:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
<template id="B:0"> to placeholder, w które powinny trafić treści. <div hidden id="S:0"> zawiera właściwą wyrenderowaną zawartość. B:0 odpowiada S:0 numerkiem (indeks od zera).
Bez JavaScriptu wyszukiwarki (patrzę na ciebie, Bing) i LLM-y widziałyby praktycznie pustą stronę z samym placeholderem template. To kompletnie zabija sens SSG!
Nie widziałem żadnego czystego sposobu na pozbycie się tych granic Suspense, więc moim rozwiązaniem było napisanie kilku testów i funkcji resolveSuspenseBoundaries, która je podmienia. Było to szybsze niż parsowanie HTML i wykonywanie skryptu czymś w stylu JSDOM. I co ważniejsze, było to konieczne do tego, co planowałem: ładnej, czytelnej strony dla wyszukiwarek i LLM-ów bez JavaScriptu, ale z obsługą granic Suspense i hydratacją po stronie klienta.
Testowanie transformacji
Zacząłem od napisania testów dla tej transformacji: wyciągnąłem z DOM-u kilka przykładów tego, co mam (z wyłączonym JavaScriptem) i tego, co chcę uzyskać (z włączonym). Wrzuciłem je do LLM-a i poprosiłem go o wygenerowanie testów, w czym jest całkiem dobry.
Te testy mieszkają w client/src/generators/ssr/renderRoute.test.ts i pilnują, żeby transformacja działała poprawnie. Obejmują między innymi:
- proste podmiany granic (lista wpisów na blogu)
- bardziej złożone granice z treścią pomiędzy template a kończącym komentarzem
- wiele granic na raz
- granice bez komentarzy oznaczających
- sytuacje brzegowe
Taki rodzaj „TDD” jest naprawdę przydatny w przypadku, gdy masz jasno określone wejścia i oczekiwane wyjścia.
Nie myl tego z podejściem „TDD wszędzie, bo Robert C. Martin tak powiedział” (które tylko spowolni tempo pracy waszego zespołu). NIE powinniście używać TDD do UI ani do części kodu, które ciągle się zmieniają!
Rozwiązanie: resolveSuspenseBoundaries
Kiedy testy były już gotowe, poprosiłem LLM-a o napisanie funkcji resolveSuspenseBoundaries. Zdecydowałem się użyć do tego cheerio, żeby uniknąć kruchości wyrażeń regularnych, chociaż RegEx skróciłby tutaj czas SSG mniej więcej o 40%.
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
Dzięki temu zamiast prawie pustej strony wyszukiwarki i LLM-y widzą w pełni wyrenderowaną zawartość.
Teraz mamy SSG działające fajnie nawet bez JavaScriptu!
<img alt="No JavaScript SSG for Foony's blogs" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
W dłuższej perspektywie niewykluczone, że React zmieni format Suspense. Być może wyrzucę ten kod do rozwiązywania granic Suspense, kiedy będę miał lepsze rozwiązanie dla stron ładowanych leniwie (które przez to wymagają granic Suspense).
Strategia hydratacji (aktualizacja: zajęło to 3 dni + 1 dodatkowy dzień)
Hydratacja jest trudna. Wiedziałem o tym. Ale po odrobinie pracy udało mi się ją uruchomić!
Łączny czas pracy nad hydratacją: 3 dni, plus 1 dodatkowy dzień na zastąpienie podejścia z dehydratacją.
Najtrudniejsze było po prostu uzyskanie tego pierwszego, minimalnego, działającego hydratu. Kiedy udało mi się wyrenderować „Hello World” razem z nawigacją, nabrałem pewności, że faktycznie nie zajmie mi to całego miesiąca!
<img alt="Foony's Hello World hydrating successfully with navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
Przy tym pierwszym minimalnym hydracie miałem wyjątkowe wyzwanie: chciałem mieć hydratację, ale jednocześnie dobre SEO dla wyszukiwarek i LLM-ów, bez zmuszania deweloperów do myślenia o granicach Suspense.
Wyzwanie
Hydratacja w Reakcie jest ekstremalnie dosłowna: jeśli DOM nie wygląda dokładnie tak, jak React oczekuje przy tym pierwszym renderze, dostajesz w konsoli miły, prawie bezużyteczny komunikat o błędzie, a React wyrzuca wszystko do kosza i renderuje od zera. Nawet bez diffu, który powiedziałby ci, co poszło nie tak!
W naszym przypadku SSG jeszcze to pogarszało na kilka sposobów:
- Po fakcie przerabialiśmy HTML, żeby usunąć/rozwiązać artefakty strumieniowego Suspense z Reacta 18 (co jest super dla botów).
- Po stronie klienta w chwili (t = 0) nie zawsze były dostępne dokładnie te same dane, które miał serwer przy renderze (dane SSG, metadane wpisu na blogu itd.).
- Nasze i18n jest domyślnie „leniwe”, co oznacza, że tłumaczeń może brakować przy pierwszym renderze, jeśli nie zapiszesz, które klucze zostały użyte podczas SSG, i nie wstrzykniesz ich zanim React zacznie renderować.
Co zadziałało (początkowe podejście: dehydratacja)
Na początku spróbowałem czegoś sprytnego i „słodkiego”: użyłem wzorca poleceń, żeby zapisywać komendy użyte do rozwiązania granic Suspense w HTML, a potem zwracałem odwrotne komendy, żeby móc przywrócić HTML do formy, jakiej React potrzebuje do hydratacji.
Liczyłem na to, że dzięki temu będę mógł wysyłać w index.html znacznie mniej bajtów. Ale jak to bywa z „sprytnymi” rozwiązaniami, poległo to na tym, że przeglądarki modyfikują HTML w subtelny sposób, na przykład usuwając albo dodając jakiś ; czy /, co rozwalało wszystkie indeksy do podmian.
Technicznie dałoby się pewnie uwzględnić te subtelne zmiany przeglądarek, ale nie zamierzałem wrzucać czegoś aż tak kruchego na produkcję.
Zamiast próbować „odwracać” transformację granic Suspense z powrotem do strumieniowego markupu Reacta, zrobiłem coś bardzo prostego:
Spakować oryginalny, nierozwiązany HTML w <script type="text">.
To podejście z „dehydratacją” działało, ale spędziłem dodatkowy dzień, żeby zastąpić je lepszym rozwiązaniem.
Lepsze podejście: podmiana granic Suspense na krytycznej ścieżce
Po pierwszej implementacji wciąż trafiałem na różne problemy z granicami Suspense. Wtedy zorientowałem się, że da się to zrobić czyściej, lepiej i prościej. Zastąpiłem podejście z dehydratacją podmianą granic Suspense na krytycznej ścieżce, która:
- ładuje krytyczną ścieżkę przed hydratacją: komponenty wstępnie załadowane podczas SSR są wykrywane i preloadowane po stronie klienta, zanim wywołamy
hydrateRoot
- jest prostsza w utrzymaniu: nie trzeba dotykać wewnętrznych mechanizmów Reacta ani parsować AST (podejście z dehydratacją wymagało parsowania i odtwarzania HTML)
- wysyła mniej bajtów: nie pakujemy już oryginalnej odpowiedzi SSR z Reacta w tagu script
- zapobiega potencjalnemu „flashowi”: nie trzeba dehydradować/hydratować HTML, więc znika potencjalne miganie widoczne dla użytkownika
Implementacja śledzi, które leniwe komponenty zostały wstępnie załadowane podczas SSR (przez SSRLazyComponentTracker), dorzuca ich ścieżki importu do danych hydratacji i preloaduje je synchronicznie przed samą hydratacją. Komponenty na krytycznej ścieżce renderują się od razu, bez granic Suspense, dokładnie tak, jak w SSR.
Dla całej reszty sprawiamy, że pierwszy render po stronie klienta zachowuje się jak SSR/SSG. To znaczy używamy tych samych danych wejściowych i udostępniamy je synchronicznie przed wywołaniem hydrateRoot. Robimy to, pakując je w nasze „ssg-data”.
Konkretnie, zmiany wyglądały tak:
Spakowanie wejściowych danych SSR w jeden tekstowy script
- Podczas SSG wstrzykujemy
<script type="text/foony-ssg" id="foony-ssg-data">...</script> tuż przed entrypointem modułu Vite.
- Ten script zawiera:
html: rozwiązany HTML, który faktycznie wysłaliśmy w pliku statycznym
ssgData: zserializowane SSGData używane przez wrapper SSR. Planuję przerobić to na Proxy albo coś podobnego, żeby trafiały tam tylko faktycznie użyte dane.
translationData: paczki klucz-wartość z tłumaczeniami, których dotknęliśmy podczas SSR
Wstrzyknięcie tych danych tuż przed hydratacją
- W
main.tsx synchronicznie:
- ustawiamy
#root.innerHTML na zserializowany, rozwiązany HTML (żeby DOM wyglądał dokładnie tak, jak to, co zobaczy hydratacja)
- owijamy aplikację w
SSGDataProvider, żeby komponenty miały to samo SSGData przy pierwszym renderze
Zrobienie natychmiastowego i18n przez wstrzyknięcie wartości tłumaczeń
- Zapisujemy konkretne obiekty tłumaczeń, do których sięgaliśmy podczas SSR, i dołączamy je w skrypcie SSG.
- Po stronie klienta wstrzykujemy je bezpośrednio do cache
LocaleQueryer przez dedykowaną metodę LocaleQueryer.inject(), dzięki czemu tłumaczenia są dostępne od razu.
I dzięki temu pierwszy render ma dokładnie te same dane, które miał SSR!
Hook useIsSSRMode() jest już zaimplementowany w client/src/generators/ssr/isSSRMode.ts:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
Ten hook zwraca true podczas SSR i przy pierwszym renderze po stronie klienta (hydratacji), a potem przełącza się na false po zamontowaniu. Komponenty takie jak UserBanner, Navbar czy Dialog już z niego korzystają, żeby uniknąć niezgodności podczas hydratacji.
- Łatanie Reacta dla lepszych diffów
Liczyłem na to, że po prostu użyję hydration-overlay. Ale projekt nie jest aktywnie rozwijany, wspiera tylko Reacta 18 i nie był gotowy na produkcję. Poprosiłem więc LLM-a, żeby sklonował repozytorium jako inspirację, i w kilka minut stworzył minimalny overlay do hydratacji. Nie potrzebowałem niczego wymyślnego, tylko czegoś, co pokaże się w czasie developmentu, żebym mógł zobaczyć, gdzie coś poszło nie tak.
Ten nowy overlay jest bardzo prosty, więc diffy nie są do końca idealne. React usuwa komentarze, dodaje ; po atrybutach style, modyfikuje białe znaki i robi jeszcze parę drobnych rzeczy, których nasz overlay na razie nie uwzględnia. Nasz overlay widzi też komentarze HTML, które React podczas hydratacji ignoruje.
<img alt="Our new hydration overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
Ale to w zupełności wystarcza, żeby zorientować się, co trzeba poprawić.
<img alt="diff of our SSG vs client first-page render for React hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
W liczbach
Żeby dać ci jakieś wyczucie, co składało się na tę implementację:
- 2 dni pracy (od startu do działającego SSG). Łącznie coś ponad 24 godziny, robione na urlopie.
- 4 dni pracy, żeby hydratacja zachowywała się porządnie, bez wyścigów asynchronicznych tłumaczeń i bez psucia wszystkiego przez
useMediaQuery.
- 1 dodatkowy dzień na zastąpienie dehydratacji podmianą granic Suspense na krytycznej ścieżce (prościej, mniej bajtów, brak potencjalnego migania).
- ~200 linii kluczowego kodu generowania SSG (
GenerateShellSsgFromSitemap.ts)
- ~120 linii kodu do rozwiązywania granic Suspense (
resolveSuspenseBoundaries w renderRoute.tsx) - Uwaga: później zostało to zastąpione podejściem z krytyczną ścieżką
- ~50 linii utili do SSR (
isSSRMode.ts)
- ~100 linii testów (
renderRoute.test.ts)
- ~150 linii polyfilli dla SSR (
setupSSREnvironment)
- Minimalne zmiany w istniejących komponentach (głównie dodanie sprawdzeń
useIsSSRMode())
Rozwiązanie jest lekkie i łatwe w utrzymaniu. Nie wymaga migracji do innego frameworka i działa z naszym istniejącym SPA w Reakcie.
Najważniejsze wnioski
Czasem własne rozwiązanie jest lepsze
Nie każdy problem potrzebuje frameworka. Dla Foony małe, autorskie rozwiązanie SSG okazało się strzałem w dziesiątkę. Jest:
- lekkie: bez ciężkich zależności i narzutu frameworka
- łatwe w utrzymaniu: prosty kod, który rozumiemy
- elastyczne: łatwe do modyfikowania i rozbudowy w razie potrzeby
- kompatybilne: działa z naszym istniejącym SPA w Reakcie bez migracji
Strumieniowe SSR w Reakcie ma swoje dziwactwa
renderToReadableStream z Reacta jest fajne do ogarniania Suspense, ale ma swoje dziwactwa. Nawet z await stream.allReady w outputcie wciąż dostajesz granice Suspense. To nie jest bug, tylko świadome zachowanie zaprojektowane pod streaming. Tyle że w SSG potrzebujemy w pełni rozwiązany HTML. Trochę wygląda to jak niedopatrzenie ze strony zespołu Reacta, że nie ogarnęli tego scenariusza w prosty sposób.
Moim rozwiązaniem było przetworzenie HTML po fakcie i rozwiązanie granic. Nie jest to może najpiękniejsze, ale jest szybkie i wystarczająco elastyczne do mojego przypadku.
TDD może być przydatne przy pracy z LLM-ami
Transformacje HTML są podatne na błędy. Jeden drobny bug i możesz rozwalić cały output SSG oraz doświadczenie użytkownika końcowego. Poprosiłem LLM-a o napisanie porządnego zestawu testów (na podstawie moich przykładów), żeby mieć pewność, że transformacja działa poprawnie.
Podsumowanie
SSG działa teraz dla Foony. Strony są w pełni wyrenderowane dla wyszukiwarek i LLM-ów, a całe rozwiązanie jest lekkie i łatwe w utrzymaniu. Hydratacja dla routów SSG zajęła mi dłużej, niż się spodziewałem (3 dni), a dodatkowy dzień poświęciłem na zastąpienie początkowego podejścia z dehydratacją podmianą granic Suspense na krytycznej ścieżce. Nowe podejście jest prostsze w utrzymaniu, wysyła mniej bajtów i zapobiega potencjalnemu miganiu przy dehydratacji/rehydratacji HTML.
Wciąż jestem w lekkim szoku, że wdrożenie własnego rozwiązania SSG zajęło tylko 2 dni. Ale czasem najlepsze rozwiązanie jest po prostu najprostsze.
W planach mam jeszcze dopracowanie dopasowania hydratacji i być może podłatanie Reacta pod lepsze debugowanie. Na ten moment Foony ma jednak działające SSG. Przez najbliższe tygodnie będę zerkał w Google Search Console i Bing Webmaster Tools, żeby zobaczyć, jaki wpływ ma to na nasze SEO.