Przejdź do treści

SSR, SSG, ISR, CSR - kiedy powstaje HTML i jakim kosztem

Profile photo of Adrian Zawadzki

Adrian Zawadzki

20 min czytania

Cztery strategie renderowania nie są wyborem z menu, tylko decyzją per route: gdzie powstaje HTML i kiedy. Każda ma inny moment generacji, inny TTFB, inny koszt infrastruktury i inny stosunek 'świeże dane vs prędkość'. Rozkładam mechanikę czterech podejść i pokazuję, kiedy które ma sens.

Cztery wersje tego samego komponentu - strona z postem blogowym:

// 1. SSR - świeży HTML na każdy request
export async function getServerSideProps() {
  const post = await fetchPost();
  return { props: { post } };
}

// 2. SSG - HTML powstaje w build time
export async function getStaticProps() {
  const post = await fetchPost();
  return { props: { post } };
}

// 3. ISR - SSG + okresowe odświeżanie w tle
export async function getStaticProps() {
  const post = await fetchPost();
  return { props: { post }, revalidate: 60 };
}

// 4. CSR - HTML powstaje u usera w przeglądarce
function Post() {
  const [post, setPost] = useState(null);
  useEffect(() => {
    fetchPost().then(setPost);
  }, []);
  return post ? <Article {...post} /> : <Skeleton />;
}

Używam tu API z Pages Routera, bo jest dobrze rozpoznawalne. Mechanika jest taka sama w Astro, Remix, SvelteKit i innych - tylko nazwy się różnią. Sam wybór strategii (gdzie i kiedy powstaje HTML) jest ponad-framework’owy.

Dla użytkownika końcowego wynik wygląda tak samo. Ale czas do pierwszego renderu, koszt infrastruktury i świeżość danych - tu różnice są ogromne. Wybór między tymi czterema podejściami to nie kwestia gustu, tylko świadoma decyzja o tym, kiedy powstaje HTML i kto za to płaci.

#Klient czy serwer - gdzie powstaje HTML

Każda strona webowa, którą widzi user, to ostatecznie HTML w jego przeglądarce. Pytanie jest tylko gdzie i kiedy ten HTML powstał:

  • Server-side - HTML jest gotowy zanim trafi do przeglądarki. Serwer lub build system robi pracę, browser tylko wyświetla.
  • Client-side - serwer wysyła minimalny shell + JS bundle. Browser pobiera, parsuje, wykonuje, dopiero wtedy generuje HTML w drzewie DOM.

Server-side dzieli się dalej w zależności od tego, kiedy dokładnie ten HTML powstał:

  • Przy każdym requeście → SSR
  • Raz, w build time → SSG
  • Raz w build time + okresowe odświeżanie w tle → ISR

Plus jeden client-side wariant:

  • W przeglądarce usera, po wykonaniu JS → CSR

Cztery momenty generacji HTML. Każdy ma inne konsekwencje dla performance, SEO, kosztu i tego, jak świeże są dane, kiedy user je zobaczy.

#SSR (Server-Side Rendering) - świeżo na każdy request

W SSR każde wejście usera na stronę uruchamia handler na serwerze:

user → request → handler na serwerze:
                    1. fetch danych (z bazy, z API)
                    2. render komponentu do HTML
                    3. response z gotowym HTML
              → browser dostaje pełny HTML
              → JS hydruje (dodaje interaktywność)

Każdy user dostaje świeży HTML, generowany w momencie requestu. Dane są tak nowe, jak ostatnie wywołanie bazy.

Plusy

  • Dane zawsze świeże (każdy request to nowe fetch).
  • HTML kompletny w pierwszym response - dobry dla SEO, dobry dla user’a z wolnym JS-em (treść widać zanim cokolwiek się sparsuje).
  • Personalizacja per user (response zależy od cookies, sesji, query params).

Minusy

  • TTFB zależy od czasu renderu na serwerze. Powolne API → powolna strona.
  • Każdy request kosztuje CPU + RAM na serwerze. Skalowanie = więcej maszyn.
  • Trudniej cache’ować na CDN (per-user response).
  • Wymagany działający serwer 24/7.

Kiedy SSR: dane muszą być świeże w momencie wyświetlenia (dashboard z live metrykami, ecommerce z zawsze-aktualnym stockiem, personalizowane feed’y), albo response zależy od konkretnego usera (cookies, A/B test, ip-geo).

web.dev - Rendering on the Web rozkłada SSR razem z innymi strategiami i pokazuje konkretne metryki TTFB / FCP / TTI per podejście.

#SSG (Static Site Generation) - raz na build

W SSG cała praca renderowania dzieje się przed deployem. Build system iteruje po wszystkich routach, dla każdego fetch’uje dane, renderuje HTML, zapisuje plik. Deploy push’uje statyczne pliki na CDN. Od tego momentu każdy user dostaje gotowy plik prosto z CDN:

build time:
  1. dla każdego routa:
       fetch danych
       render do HTML
       zapis pliku
  2. deploy gotowych plików na CDN

runtime:
  user → request → CDN node odpowiada gotowym plikiem
        → browser dostaje HTML
        → JS hydruje

Plusy

  • Bardzo szybki TTFB (CDN serwuje plik, nic nie renderuje).
  • Tani hosting (statyczne pliki serwuje każdy CDN za grosze).
  • Łatwo skalować (CDN robi to za ciebie).
  • Idealny dla SEO i Core Web Vitals (LCP/FCP na poziomie milisekund).
  • Działa nawet jeśli backend padł - pliki są już na CDN.

Minusy

  • Dane są “zamrożone” w momencie build’a. Zmiana w bazie nie pokaże się aż do następnego deploya.
  • Każda zmiana = pełny build. Dla 50 routów to ok, dla 50 000 routów to godziny.
  • Brak personalizacji per user (każdy widzi ten sam plik).

Kiedy SSG: treść zmienia się rzadko, można planować deploye (blog, dokumentacja, marketing pages, landing pages, portfolio). Praktycznie default dla content-driven sites.

#ISR (Incremental Static Regeneration) - SSG plus odświeżanie

ISR to hybryda SSG i SSR. Strona jest pre-rendered w build time (jak SSG), ale serwer trzyma timestamp ostatniej regeneracji. Po przekroczeniu progu (revalidate: 60 sekund) następny request dostaje stary cached HTML, a w tle uruchamia się regeneracja. Kolejni userzy dostają już nową wersję:

build: render i zapis wszystkich routów do cache (jak SSG).
       cache startuje jako świeży, revalidate = 60s.

t=0s    deploy, cache świeży

t=10s   user A → jesteśmy w oknie 60s, cache świeży
               → serwuj z cache od razu, nic w tle

t=45s   user B → wciąż w oknie, cache świeży
               → serwuj z cache od razu, nic w tle

t=65s   user C → okno 60s minęło, cache przeterminowany
               → serwuj STARĄ wersję z cache OD RAZU (user nie czeka)
               → w tle: fetch + render + podmiana cache

t=66s   user D → regeneracja w tle wciąż trwa
               → dalej STARA wersja z cache

t=80s   user E → cache podmieniony, okno liczy od nowa
               → serwuj NOWĄ wersję z cache

To, co właśnie zobaczyłeś, nazywa się stale-while-revalidate: serwuj starą wersję od ręki, odśwież w tle. Sedno: nikt nigdy nie czeka na regenerację. User dostaje cache natychmiast (jak user C), a świeższa wersja trafia do kolejnych (jak user E).

Plusy

  • TTFB bliski SSG (cache hit serwowany od ręki).
  • Dane świeższe niż w czystym SSG (revalidate co N sekund).
  • Skalowalność CDN-like (większość requestów idzie z cache).
  • Można regenerować pojedyncze routy bez pełnego build’a.

Minusy

  • Część userów widzi stare dane (między revalidate a faktyczną regeneracją).
  • Komplikacja deployment - wymaga serwera, który zna timestamp cache’a (nie czysty CDN).
  • Pokazuje czasem nieaktualne dane, a nie wszędzie to akceptowalne. Na przykład stan magazynowy w sklepie - ISR może serwować “dostępny”, gdy produkt już się wyprzedał (stary cache do najbliższej regeneracji). Tu lepszy SSR - zawsze świeży stan.
  • Vendor lock-in: implementacje różnią się między platformami (Vercel, Netlify, Cloudflare).

Kiedy ISR: treść zmienia się okazjonalnie i opóźnienie kilku sekund nie jest problemem (blog z dużą liczbą postów, ecommerce katalog produktów, listingi ofert). Złoty środek między prędkością SSG a świeżością SSR.

#CSR (Client-Side Rendering) - HTML powstaje u usera

W CSR serwer wysyła minimalny HTML shell plus JS bundle. Shell to pusty szkielet strony - skorupa bez treści, zwykle nie więcej niż <div id="root"></div>, w którą dopiero JS wstrzyknie zawartość. Browser pobiera, parsuje, wykonuje JS, dopiero wtedy JS generuje DOM. Dane fetch’owane są z przeglądarki (zwykle przez useEffect + fetch):

user → request → serwer odpowiada HTML shellem + JS bundle
              → browser pobiera + parsuje
              → JS się wykonuje
              → useEffect odpala fetch danych
              → response wraca z API
              → React (lub inny framework) renderuje komponent
              → user widzi treść

Każdy z tych kroków zajmuje czas. Treść użytkownik widzi dopiero w ostatnim kroku.

Plusy

  • Wysoka interaktywność - po pierwszym renderze wszystko reaguje natychmiast (CSR nie ma osobnego kroku hydracji - drzewo komponentów powstaje w browserze od razu jako interaktywne).
  • Real-time updates łatwiej zrobić (WebSocket → setState).
  • Wystarczy najprostszy hosting, który tylko oddaje gotowe pliki - bez żadnego serwera renderującego (cały render dzieje się w przeglądarce).
  • Granularne kontrolowanie tego, kiedy i co pobierać (lazy loading, infinite scroll, optimistic UI).

Minusy

  • Pusty HTML w pierwszym response - dla crawlerów SEO bez JS-a strona jest pusta. Google sobie radzi (rendery JS), ale wolniej i nie tak dobrze jak z gotowym HTML.
  • Pierwsza treść pojawia się późno - user widzi skeleton/spinner, zanim cokolwiek się wyrenderuje (opóźniony FCP). Pełna interaktywność (TTI) przychodzi jeszcze później, gdy JS się sparsuje i wykona.
  • Cały render odbywa się na urządzeniu usera - słabsze urządzenie = wolniejsza strona.
  • Dwie wymiany z serwerem do pierwszej treści: najpierw HTML+JS, potem fetch danych.

Kiedy CSR: aplikacje typu SaaS dashboard / admin panel / wewnętrzne narzędzia, gdzie SEO nie ma znaczenia, user już zalogowany, długa sesja, i interaktywność jest sercem produktu. Klasyczne SPA case.

#Co wybrać i dlaczego

Cztery strategie porównane po siedmiu kryteriach:

SSRSSGISRCSR
TTFBzmienny (zależy od API)bardzo niskibardzo niskiniski (HTML shell)
FCPdobrybardzo dobrybardzo dobrysłaby
SEObardzo dobrybardzo dobrybardzo dobryzależy od crawlera
Świeżość danychmaksymalnatylko z build’emrevalidate timerper fetch w runtime
Koszt infrawysoki (serwer 24/7)bardzo niski (CDN)średnibardzo niski (CDN)
Komplikacjaśrednianiskawysokaśrednia
Personalizacjataknienie (bez tricków)tak

Klucz w wyborze: decyduje per route, nie per app. Realna aplikacja używa mixa:

  • / (landing) - SSG (rzadko się zmienia, SEO ważne)
  • /blog/[slug] - SSG lub ISR (treść stała, nowe posty co tydzień)
  • /shop/[product] - ISR (stock zmienia się okresowo, ranking SEO ważny)
  • /dashboard - CSR (zalogowany user, full interactivity)
  • /checkout - SSR (zawsze świeże dane, personalizacja per user)
  • /search?q=... - SSR (dynamiczne na podstawie query params)

Frameworki jak Next.js, Astro czy Remix dają wybór per route. Nie ma już sytuacji “ta aplikacja jest SSR” - jest “ta aplikacja używa mieszanki strategii, każdy route ma swoją”.

Spróbuj poniżej - kliknij strategię, żeby zobaczyć, gdzie na osi czasu powstaje HTML i jakie ma to konsekwencje dla TTFB, świeżości, SEO i kosztu:

Wybierz strategię:
Gdzie powstaje HTML:
  1. build
  2. deploy
  3. requestHTML powstaje tu
  4. browser
Trade-offy:
Moment generacji HTML
request
TTFB
Zmienny - zależy od czasu renderu na serwerze (typowo 100-500 ms).
Świeżość danych
Maksymalna - każdy request to świeże dane.
SEO
Bardzo dobre - HTML kompletny w pierwszym response.
Koszt infrastruktury
Wysoki - serwer musi działać 24/7 i obsłużyć każdy request.
Kiedy używać
Dashboardy, ecommerce, personalizowane feed'y, search - wszędzie tam, gdzie dane muszą być świeże w momencie wyświetlenia albo response zależy od konkretnego usera.

#Modern - streaming, partial hydration, RSC

Cztery podstawowe strategie wyczerpują cały wybór. To, co niżej, nie jest kolejną opcją obok nich - to warianty nałożone na tę czwórkę, najczęściej na SSR. Nadal wybierasz SSR / SSG / ISR / CSR; te dodatki zmieniają tylko jak HTML jest dostarczany albo ile JS-a ląduje u klienta, nie kiedy i gdzie HTML powstaje.

Streaming SSR (React 18, Suspense) - to wciąż SSR, tylko dostarczany kawałkami. Serwer nie czeka, aż cały HTML się zrenderuje - wysyła fragmenty (chunks) w miarę gotowości. User widzi nagłówek strony, podczas gdy serwer dalej liczy listę produktów. TTFB dużo niższy niż w zwykłym SSR - serwer wysyła górę strony od razu, zamiast czekać, aż doliczą się wszystkie dane. FCP też szybsze: pierwszą treść widać, zanim reszta strony się dorenderuje.

Partial hydration / Islands (Astro, częściowo Next.js z RSC) - server-side renderuje cały HTML, ale hydruje tylko niektóre fragmenty (te które potrzebują interaktywności). Reszta zostaje statycznym HTML-em. Mniej JS-a do pobrania, mniej JS-a do wykonania, lepsze metryki interaktywności na słabszych urządzeniach. (Qwik idzie jeszcze dalej z resumability - w ogóle pomija hydrację, wznawiając stan z serializowanego HTML, zamiast odtwarzać go w przeglądarce.)

React Server Components (RSC) - hybryda server-rendered i client-side w obrębie jednego drzewa komponentów. Komponenty oznaczone jako “server” renderują się i pozostają na serwerze (nie trafiają jako JS do browsera). “Client” komponenty są hydratowane normalnie. Granica jest płynna - server component może osadzić w sobie client component. Najlepiej pasuje, gdy strona to w większości statyczna treść (server components, zero JS-a wysyłanego do klienta), a interaktywność potrzebna jest tylko gdzieniegdzie (client components).

Wszystkie te warianty są rozszerzeniami tej samej zasady (kiedy i gdzie powstaje HTML), nie nowymi strategiami. Można je czytać jako “SSR z lepszą dostarczalnością” (streaming), “SSR z mniej JS-a po stronie klienta” (islands), “SSR z granicą sterowaną na poziomie komponentu” (RSC).

Patterns.dev - Rendering Patterns rozkłada każdy z tych wariantów w osobnej notce z diagramami i kodem.

#Typowe pułapki - co najczęściej psuje się w każdej strategii

Każda strategia ma swoją kolekcję problemów, które wracają w prawdziwych projektach. To dobry zestaw do interview-prep i do code review.

SSR

  • window / document na serwerze. Node.js nie ma DOM API. Próba ich użycia w komponencie renderowanym na serwerze rzuca ReferenceError. Fix: warunkować przez typeof window !== 'undefined', użyć useEffect, albo oznaczyć komponent jako client-only.
  • Hydration mismatch. Klient wyrenderuje inaczej niż serwer (np. new Date().toLocaleString() z innej strefy, Math.random(), dane z localStorage). Framework odbudowuje fragment od zera, tracąc benefit pre-renderu i logując warning. Fix: deterministyczny render (ten sam input zawsze daje ten sam HTML - bez Math.random(), czasu czy strefy w markupie), suppressHydrationWarning (prop Reacta mówiący „wiem, że tu serwer i klient się różnią, nie ostrzegaj”) dla świadomie dopuszczonych różnic, client-only dla naprawdę zmiennych fragmentów.
  • Singleton współdzielony między requestami. Globalny obiekt (np. cache, config, user state) zainicjalizowany raz przy starcie serwera widzą wszyscy. Jeden user może zobaczyć dane drugiego - klasyczny memory + security bug.
  • Powolne API blokuje cały render. SSR czeka, aż wszystkie dane są dostępne, zanim wyśle HTML. Jedno powolne zapytanie → TTFB sekundy. Fix: streaming SSR z Suspense, równoległe zapytania, timeouty.

SSG

  • Stale content bez rebuilda. Zmienisz post w CMS-ie, ale dopóki nie odpalisz npm run build i nie zdeployujesz, user widzi starą wersję. Rozwiązanie: webhook → CI → automatyczny redeploy, albo ISR.
  • Czas buildu rośnie lawinowo przy dużej liczbie stron. SSG dla 100k produktów = build trwa godziny. Sam się o to potknąłem - w jednym z moich projektów buildy Gatsby dochodziły do 15-20 minut (wąskim gardłem był pipeline obrazów) i Netlify potrafił uciąć je na limicie czasu. Fix: fallback: blocking / on-demand generation, albo SSR per route z agresywnym caching.
  • Brak dynamicznej ścieżki = 404. Strona, której nie wygenerowałeś w build, nie istnieje. Klasyczna pułapka przy nowych itemach dodanych po deployu.
  • Environment variables baked-in. Wartości process.env.* użyte w build trafiają do statycznego HTML. Zmiana wymaga rebuilda. Nigdy nie wstawiaj sekretów do publicznych zmiennych.

ISR

  • Stary content widoczny “tuż po revalidate”. Pierwszy user po upływie czasu revalidate dostaje stary HTML (cache), a regeneracja idzie w tle. Drugi user widzi nową wersję. Konsekwencja: dwóch userów na tej samej stronie w tej samej sekundzie widzi różne dane. Akceptowalne dla bloga, problematyczne dla cen produktów.
  • Zbyt krótki revalidate = de facto SSR. Ustawisz 5 sekund, każdy request regeneruje, koszty rosną do poziomu SSR bez benefitów cache.
  • Vendor lock-in. Pełny ISR (z on-demand revalidation, granular cache tags) najpełniej działa na Vercel + Next.js. Netlify i Cloudflare mają własne odpowiedniki (Netlify np. przez standardowe stale-while-revalidate headers), ale o innej semantyce i konfiguracji.
  • Race condition przy regeneracji. Dwa requesty trafiają jednocześnie po upływie czasu revalidate - oba próbują regenerować. Niektóre platformy deduplikują, inne nie.

CSR

  • SEO disaster. Crawler dostaje pusty <div id="root"></div> i nic więcej. Google od dawna umie wykonać JS, ale: opóźnione indeksowanie, social-media crawlery (FB, Twitter, Slack) generalnie JS-a nie odpalają. Open Graph + Twitter Cards przepadają.
  • Layout shift przy ładowaniu data. User widzi skeleton, potem treść, potem dane się dociągają, potem reszta doskakuje. CLS w Lighthouse leci na czerwono.
  • Auth flicker. Strona renderuje “Sign in” przez chwilę przed sprawdzeniem tokena z localStorage. Klasyczny migający header logged-in / logged-out.
  • Memory leaks z useEffect. Subscribe do WebSocketa, setInterval, listener na window - bez cleanup w długo żyjącej stronie SPA, użycie pamięci rośnie z każdą nawigacją.

Cross-cutting (każda strategia z server-side renderem)

  • Theme flicker (FOUC). FOUC = Flash of Unstyled Content: krótki błysk strony, zanim zaaplikują się docelowe style. Tu konkretnie: dark mode trzymany w localStorage - serwer go nie zna i wysyła HTML w jednym motywie, klient się hydratuje i przełącza na drugi. Błysk białego/czarnego ekranu. Fix: inline script przed <body>, który ustawia klasę z localStorage przed pierwszym paintem.
  • Locale mismatch. Serwer renderuje w jednym języku, klient ma preferowany inny - albo dwa renders (czas i koszt), albo flash treści.
  • Date / timezone divergence. new Date().toLocaleString() zwróci coś innego w Node (zwykle UTC) niż w browserze (lokalna strefa). Klasyczna przyczyna hydration mismatch.

#Strategie to nie wybór z menu, to decyzja per route

Cztery podstawowe strategie - SSR, SSG, ISR, CSR - różnią się tak naprawdę jednym: kiedy i gdzie powstaje HTML. Wszystkie inne różnice (TTFB, SEO, koszt, świeżość, interaktywność) wynikają z tego jednego wyboru.

Pragmatyczna ścieżka wyboru:

  1. Default = SSG. Treść statyczna, deploy = re-build, CDN serwuje. Najtaniej, najszybciej, najmniej rzeczy może się zepsuć.
  2. Jeśli dane się zmieniają, ale dopuszczalne jest opóźnienie kilku sekund → ISR. SSG performance, świeżość rośnie automatycznie.
  3. Jeśli dane muszą być świeże w momencie wyświetlenia lub zależą od usera → SSR. Ceną jest TTFB i koszt serwera.
  4. Jeśli SEO nie ma znaczenia i interaktywność jest sercem produktu → CSR. Klasyczne SPA.
  5. Per route, nie per app. Mix strategii to norma, nie wyjątek.

Framework to detal implementacyjny - Next.js, Astro, Remix, SvelteKit, Nuxt dają te same cztery opcje pod różnymi API. Wybór strategii nie zaczyna się od “jaki framework”, tylko od “jakie dane i jak często się zmieniają”.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz