Zapisy na szkolenie w najniższej cenie do 6 lutego.
00 dni
:
00 godz
:
00 min
:
00 sek
Dołącz teraz

Next.js 16: Partial Prerendering w praktyce. Koniec kompromisu między SSG a SSR?

Next.js 16: Partial Prerendering w praktyce. Koniec kompromisu między SSG a SSR?

Kiedy budujemy publiczne strony marketingowe, wybór jest prosty: Static Site Generation (SSG). Wszystko jest gotowe w czasie budowania, CDN serwuje pliki w milisekundach, a wskaźnik TTFB (Time to First Byte) świeci się na zielono.

Schody zaczynają się, gdy wchodzimy w świat aplikacji typu Dashboard, Portal czy SaaS. Tam treść zależy od tego, kto jest zalogowany, jaką organizację wybrał i jakie ma uprawnienia. Do tej pory mieliśmy dwie drogi, z których żadna nie była idealna:

Czysty Client-Side Rendering (CSR): Szybki TTFB (leci pusta strona), ale potem użytkownik patrzy na kręcące się loadery, layout skacze (CLS) oraz nie najlepsze wyniki SEO.

Server-Side Rendering (SSR): Generujemy wszystko na serwerze. Plus? Użytkownik dostaje gotową treść. Minus? Waterfall. Serwer musi pobrać dane z bazy, wyrenderować HTML i dopiero wtedy wysłać cokolwiek do przeglądarki. TTFB rośnie drastycznie, bo najwolniejsze zapytanie do API blokuje całą stronę.

Mieliśmy wcześniej możliwość łączenia tych światów jednak rozwiązania te wymagały często kombinowania i pójścia na komporomisy w korzystaniu z tego co najlepsze w obydwu światach. W Next.js 16 pojawia się rozwiązanie, które zmienia zasady gry i ułatwia cały proces: Partial Prerendering (PPR).

W tym artykule pokażę Wam, jak wdrożyłem PPR w dynamicznyej aplikacji typu Portal z wykorzystaniem TanStack Query, i dlaczego inżynierskie detale – takie jak użycie void zamiast await – decydują o sukcesie tej optymalizacji.

Czym właściwie jest Partial Prerendering (PPR)?

Mówiąc najprościej: PPR pozwala serwerowi wysłać statyczną “skorupę” (Shell) strony natychmiast, a dynamiczne części dosłać w tym samym połączeniu HTTP, gdy tylko będą gotowe.

Wyobraź sobie, że zamawiasz paczkę.

SSR to kurier, który czeka w magazynie, aż skompletują całe Twoje zamówienie (książkę, lodówkę i długopis), i dopiero wtedy rusza w drogę. Czekasz długo na wszystko.

PPR to sytuacja, w której kurier puka do drzwi od razu z pudełkiem (layout, nagłówek, sidebar), a w międzyczasie dron dolatuje i “wrzuca” do środka dynamiczną zawartość (tabelki, wykresy), gdy tylko zostanie ona znaleziona w magazynie.

Przełóżmy tę metaforę na to, co faktycznie dzieje się w sieci. Poniższy schemat obrazuje różnicę w przepływie danych między serwerem a przeglądarką.

W tradycyjnym SSR jesteśmy blokowani przez konieczność wygenerowania jednego, monolitycznego pliku HTML. Dopóki serwer nie pobierze wszystkich danych, przeglądarka czeka na “pustym biegu”.

W PPR zamieniamy ten monolit na strumieniowanie. Zamiast wielkiego bloku, serwer wysyła odpowiedź kawałkami:

Natychmiast otrzymujesz lekki Static Shell (szkielet strony).

Dynamiczne dane są dosyłane równolegle w formie mniejszych paczek danych, chunków HTML.

Dzięki temu, że te chunki spływają niezależnie, wskaźnik TTFB spada drastycznie, a użytkownik widzi interfejs niemal natychmiast.

Porównanie wyników WebPageTest - blokujący layout vs streaming

Kliknij w zdjęcie, aby powiększyć

Dla dashboardów to świetne rozwiąznie. Użytkownik widzi interfejs natychmiast (nawigacja, stopka, struktura), a dane ładują się równolegle.

Case Study: Dashboard Portalu (Next.js 16 + TanStack Query)

W jednym z moich ostatnich projektów (portal z dynamicznymi danymi per organizacja) postanowiłem wykorzystać PPR. Stack technologiczny to standard w nowoczesnym Reactcie:

Next.js 16 (Canary/RC)

TanStack Query (React Query) v5

Oto jak wygląda implementacja w kodzie.

1. Konfiguracja

Jeżeli używacie nexta w wersji 16 lub nowszej PPR powinien być domyślne dostępny wasz plik next.config.ts powinien wyglądać mniej więcej tak:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  reactCompiler: true,
};

export default nextConfig;

2. Sercem jest React Query (Dehydration Trick)

Większość tutoriali pokazuje PPR na gołym fetchu. Ale w dużych aplikacjach chcemy cache’owania, deduplikacji i zarządzania stanem, które daje TanStack Query.

Kluczowe jest tutaj odpowiednie skonfigurowanie QueryClient. Zwróćcie uwagę na shouldDehydrateQuery. Chcemy przekazać do klienta nie tylko zakończone zapytania, ale też te w stanie pending. Dlaczego? O tym za chwilę.

// lib/query-client.ts
import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from "@tanstack/react-query";
import { cache } from "react";

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30 * 1000,
      },
      dehydrate: {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
    },
  });
}

export const getQueryClient = cache(makeQueryClient);

3. Server Component: Magia słowa void

To jest najważniejszy moment tego artykułu. Spójrzcie na komponent strony InvoicesPage.

Tradycyjnie w SSR zrobilibyśmy: await queryClient.prefetchQuery(…)

To błąd w kontekście PPR! Jeśli zrobisz await, serwer zatrzyma się w tym miejscu. Nie wyśle statycznego Shella (nagłówka, menu), dopóki dane się nie pobiorą. Zabijasz ideę PPR, wracając do klasycznego, blokującego SSR.

Rozwiązanie? Pattern void.

// app/invoices/page.tsx
import { Suspense } from "react";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { getQueryClient } from "@/lib/query-client";
// ... importy komponentów

const InvoicesPage = () => {
  return (
    <div className="w-full min-h-[calc(100vh-120px)] flex flex-col gap-y-6 relative">
      <div className="pointer-events-none z-0">
        {/_ To jest część statyczna (SHELL) - wyrenderuje się natychmiast _/}
        <PageHeader heading="Invoices" />
      </div>

      {/* Suspense wyznacza granicę PPR. To co w środku, będzie streamowane */}
      <Suspense fallback={<InvoicesTableLoading />}>
        <InvoicesPageContent />
      </Suspense>
    </div>
  );
};

const InvoicesPageContent = async () => {
  const siteId = await getSiteId(); // Szybkie pobranie ID z cookies/headers
  const queryClient = getQueryClient();

  // KLUCZOWE: Używamy void, a nie await!
  // Rozpoczynamy pobieranie, ale nie blokujemy renderowania.
  void queryClient.prefetchQuery({
    queryKey: ["get-invoices", siteId, 1],
    queryFn: () => getInvoices({ siteId, page: 1 }),
  });

  return (
    // Przekazujemy stan (w tym pending promise) do klienta
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ErrorBoundary FallbackComponent={ErrorCard}>
        <Suspense fallback={loadingFallback}>
          <InvoicesClient siteId={siteId} />
        </Suspense>
      </ErrorBoundary>
    </HydrationBoundary>
  );
};

export default InvoicesPage;

Co tu się wydarzyło?

Użycie void sprawia, że prefetchQuery startuje w tle.

Next.js natychmiast renderuje InvoicesPage (statyczny shell) i wysyła go do przeglądarki.

Ponieważ InvoicesPageContent jest w Suspense i korzysta z danych (poprzez hydrację stanu pending), Next.js “wie”, że ma tu dziurę do wypełnienia.

Promise z danymi jest streamowany do klienta. Klient “przejmuje” ten promise dzięki hydracji i useSuspenseQuery.

4. Client Component: Konsumpcja

Po stronie klienta kod jest czysty i przyjemny. Dzięki useSuspenseQuery, nie musimy martwić się o stany isLoading czy isError wewnątrz komponentu – tym zajmuje się Suspense i Error Boundary wyżej.

// components/invoices-client.tsx
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";
// ... reszta importów

export const InvoicesClient = ({ siteId }: { siteId: string | null }) => {

    // Jeśli dane przyszły z serwera - mamy je od razu.
    // Jeśli jeszcze lecą (stream) - Suspense trzyma fallback.
    const { data } = useSuspenseQuery({
        queryKey: ["get-invoices", siteId],
        queryFn: () => getInvoices({ /* ... */ }),
    });

    const invoices = data.data;

    return (
        // ... renderowanie UI
    );
};

5. Efekt końcowy

Po wdrożeniu PPR z powyższą implementacją, tak wyglądał wizalny efekt w naszym portalu:

Na powyższym nagraniu widać, że całe UI jest bardzo szybkie i przyjemne w odbiorze. Skeletony ładują się wewnątrz gotowego layoutu, co daje wrażenie błyskawicznej reakcji aplikacji oraz rewalidacja danych w tabelce Invoices po zmianie organizacji jest bardzo płynna.

Dlaczego to jest dobre rozwiązanie w tym przypadku?

W tradycyjnym podejściu (SSR bez PPR), gdyby getInvoices trwało 500ms, użytkownik przez pół sekundy widziałby biały ekran. Przeglądarka kręciłaby kółkiem w miejscu favicony.

Z PPR i powyższą implementacją:

TTFB jest błyskawiczne: Serwer natychmiast wysyła nagłówki i layout. Użytkownik widzi strukturę strony w ułamku sekundy.

Brak Waterfalla na Kliencie: Nie czekamy na załadowanie JS, żeby dopiero wystrzelić zapytanie (jak w zwykłym CSR). Zapytanie startuje na serwerze równo z requestem.

Lepsze UX: Szkielety (Skeletons) pojawiają się wewnątrz gotowego layoutu, co jest postrzegane przez mózg jako “szybsze” działanie aplikacji.

Pułapki, na które musisz uważać

Wdrożenie tego nie było bezbolesne. Dwie rzeczy, o których warto pamiętać:

Suspense Boundaries: Jeśli nie opakujesz dynamicznego komponentu w Suspense, Next.js może uznać całą trasę za dynamiczną i zrezygnować z PPR, cofając się do pełnego SSR.

Params i Cookies: Dostęp do searchParams, headers() czy cookies() w komponencie automatycznie wyrzuca go ze statycznego renderowania. Dlatego tak ważne jest izolowanie tych odwołań wewnątrz Suspense lub w specyficznych komponentach, które są “dziurami” w statycznym layoucie.

Co dalej? Cache to dopiero początek

Next.js 16 z PPR sprawia, że Twoja aplikacja wygląda na szybką (świetny User Experience). Ale czy jest wydajna pod spodem?

Włączenie PPR to pierwszy krok. W kolejnym artykule weźmiemy na warsztat drugą nowość: Cache Components oraz dyrektywę “use cache” i cacheLife. Przejdziemy przez to, jak sprawić, by ten szybko ładujący się dashboard nie “zabił” Waszej bazy danych nadmiarowymi zapytaniami.

Podsumowanie

Chcesz wejść głębiej w temat wydajności już teraz? To, co tutaj pokazałem, to fundament nowoczesnego Web Performance. Jeśli chcesz nauczyć się diagnozować wąskie gardła, rozumieć metryki Core Web Vitals i optymalizować aplikacje Reactowe i Next jsowe na poziomie architektonicznym oraz pozostać na czasie z nowościami w świecie Next.js/Reacta – zapraszam Cię na nasze szkolenie:

👉 Sprawdź agendę: Szkolenie WDI - Web Performance City


Zobacz nasze wideo

Nie zwlekaj. Dołącz do gangu i przejdź przez misje.

To szkolenie to masa wiedzy, praktyki, ale też dobrej zabawy. Wierzymy, że edukacja może być po prostu FAJNA :-)

Dołączam i widzimy się 10-11. lutego 2026
WDI Web Performance City - trenerzy Bartek Miś i Jarek Gad