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.
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
