Przyspieszanie aplikacji Next.js często zaczyna się od złego pytania: „co możemy zoptymalizować?”. To za szerokie. Po chwili zespół ściska obrazy, przenosi komponenty, usuwa biblioteki, zmienia cache i liczy, że wykresy się poprawią. Czasem się poprawiają. Częściej trudno powiedzieć, która zmiana pomogła i czy problem w ogóle był tam, gdzie szukaliśmy.
Lepsze pytanie brzmi: która trasa jest wolna, dla kogo, w którym momencie i z jakiego powodu? Dopiero wtedy optymalizacja ma sens. Ten artykuł prowadzi przez praktyczny proces: od pomiaru, przez diagnozę, po konkretne techniki w Next.js — Server Components, cache, Cache Components, Partial Prerendering, analizę bundle, obrazy i skrypty zewnętrzne.
Szybka aplikacja to nie przypadek
Szybkość nie jest jedną metryką. Aplikacja może mieć szybki pierwszy bajt, ale wysyłać za dużo JavaScriptu. Może mieć mały bundle, ale czekać na wolny CMS. Może mieć dobry wynik Lighthouse na stronie głównej, ale bardzo słaby widok szczegółowy po zalogowaniu.
Dlatego nie zaczynaj od ogólnego „optymalizujemy Next.js”. Zacznij od jednej trasy i jednego objawu:
długo czekamy na pierwszą odpowiedź serwera,
użytkownik widzi pusty ekran,
interakcja działa dopiero po kilku sekundach,
jedna sekcja ładuje się znacznie dłużej niż reszta,
produkcja jest wolna, ale lokalnie wszystko wygląda dobrze.
Każdy z tych objawów ma inną przyczynę. Inaczej diagnozuje się wolne fetch, inaczej zbyt duży client component, a inaczej ciężki skrypt marketingowy.
Ustal punkt odniesienia
Zanim zmienisz kod, zapisz punkt odniesienia. Minimum to build i kilka powtarzalnych pomiarów dla wybranej trasy.
pnpm build
pnpm checkpnpm build
pnpm checkWynik builda pokaże rozmiar pierwszego ładowania dla tras. Jeśli jedna strona wysyła dużo więcej JavaScriptu niż podobne widoki, to sygnał, żeby sprawdzić komponenty klienckie i importy.
Jeżeli projekt ma analizator bundle, odpal go osobno:
ANALYZE=true pnpm buildANALYZE=true pnpm buildNie porównuj wyników z pamięci. Zapisz konkretne liczby: first-load JS, czas odpowiedzi, LCP, TBT, liczbę requestów i największe zasoby.
Oddziel problem serwera od problemu przeglądarki
Pierwszy podział jest prosty: czy użytkownik czeka na HTML, czy na JavaScript i zasoby w przeglądarce?
Jeżeli TTFB jest wysokie, patrz na serwer: CMS, bazę danych, zewnętrzne API, cache, generowanie metadanych i dynamiczne funkcje Next.js. Jeżeli TTFB jest rozsądne, ale interakcja jest późna, patrz na bundle, hydration, skrypty zewnętrzne i ciężkie komponenty klienckie.
W praktyce warto mieć prosty helper do mierzenia kosztownych operacji po stronie serwera:
export async function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
const duration = Math.round(performance.now() - start);
console.info(`[perf] ${label}: ${duration}ms`);
}
}export async function measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
const duration = Math.round(performance.now() - start);
console.info(`[perf] ${label}: ${duration}ms`);
}
}W produkcji lepiej wysyłać takie dane do narzędzia obserwowalności, ale nawet lokalny pomiar szybko pokazuje, czy problemem jest CMS, renderowanie czy kilka powtarzających się zapytań.
Server Components jako domyślny wybór
W App Routerze komponent powinien być serwerowy, dopóki naprawdę nie potrzebuje przeglądarki. use client jest potrzebne dla stanu, efektów, obsługi kliknięć i API przeglądarki. Nie jest potrzebne do renderowania tekstu, linków, kart, listy artykułów ani danych pobranych na serwerze.
export default async function BlogPage() {
const posts = await getBlogPosts();
return <BlogList posts={posts} />;
}export default async function BlogPage() {
const posts = await getBlogPosts();
return <BlogList posts={posts} />;
}Jeżeli BlogList tylko renderuje dane, nie powinien być komponentem klienckim. Interaktywność przenieś do najmniejszego możliwego dziecka:
// Server component
export function BlogCard({ post }: { post: BlogPost }) {
return (
<article>
<h2>{post.title}</h2>
<SaveButton postId={post.id} />
</article>
);
}// Server component
export function BlogCard({ post }: { post: BlogPost }) {
return (
<article>
<h2>{post.title}</h2>
<SaveButton postId={post.id} />
</article>
);
}Dzięki temu przeglądarka dostaje mniej JavaScriptu, a serwer nadal może złożyć kompletny HTML.
Cache nie jest jedną przełączką
Cache w Next.js działa dobrze wtedy, gdy świadomie opisujesz świeżość danych. Inaczej łatwo zrobić jedną z dwóch rzeczy: cache’ować coś, co powinno być osobne dla użytkownika, albo pobierać na żywo coś, co spokojnie mogłoby być odświeżane co kilka minut.
export async function getCaseStudies() {
const response = await fetch("https://example.com/api/case-studies", {
next: { revalidate: 3600 },
});
return response.json();
}export async function getCaseStudies() {
const response = await fetch("https://example.com/api/case-studies", {
next: { revalidate: 3600 },
});
return response.json();
}Dla treści CMS często wystarczy rewalidacja po czasie albo webhook po publikacji. Dla danych użytkownika potrzebujesz innego podejścia, bo wspólny cache może być błędem bezpieczeństwa.
Unikaj powtarzania tych samych zapytań
Jedna trasa potrafi pobrać ten sam dokument kilka razy: raz do metadanych, raz do treści strony, raz do JSON-LD i raz do powiązanych sekcji. To łatwe do przeoczenia, bo każde zapytanie jest ukryte w innym helperze.
import { cache } from "react";
export const getPost = cache(async (slug: string, locale: "en" | "pl") => {
return sanityClient.fetch(postQuery, { slug, locale });
});import { cache } from "react";
export const getPost = cache(async (slug: string, locale: "en" | "pl") => {
return sanityClient.fetch(postQuery, { slug, locale });
});Ważne: cache’uj funkcję na właściwym poziomie. Jeżeli wynik zależy od locale, preview, userId albo feature flagi, te wartości muszą być częścią argumentów lub klucza.
Cache Components i use cache
W nowszych wersjach Next.js warto patrzeć nie tylko na klasyczne fetch z revalidate, ale też na Cache Components. Pozwalają opisać, które fragmenty są cache’owalne, a które pozostają dynamiczne.
export async function FeaturedPosts() {
'use cache';
const posts = await getFeaturedPosts();
return <PostGrid posts={posts} />;
}export async function FeaturedPosts() {
'use cache';
const posts = await getFeaturedPosts();
return <PostGrid posts={posts} />;
}To pomaga szczególnie wtedy, gdy strona ma statyczną część marketingową i kilka dynamicznych wysp. Nie wszystko musi czekać na najwolniejszy fragment.
Partial Prerendering: statyczna skorupa, dynamiczne wyspy
Partial Prerendering pozwala traktować stronę jak połączenie stabilnej skorupy i dynamicznych fragmentów. Użytkownik szybciej widzi strukturę strony, a dynamiczne sekcje dołączają później przez Suspense.
import { Suspense } from "react";
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<RelatedPostsSkeleton />}>
<RelatedPosts />
</Suspense>
</>
);
}import { Suspense } from "react";
export default function Page() {
return (
<>
<Hero />
<Suspense fallback={<RelatedPostsSkeleton />}>
<RelatedPosts />
</Suspense>
</>
);
}To nie jest pretekst, żeby ukryć wolne zapytania pod skeletonem. To sposób, żeby wolna sekcja nie blokowała całej strony, jeśli biznesowo może doładować się później.
Zmniejsz bundle przez granice use client
Jeżeli jedna dyrektywa use client znajduje się wysoko w drzewie, cały importowany fragment może trafić do przeglądarki. To częsta przyczyna wzrostu bundle po pozornie niewielkich zmianach UI.
Zamiast oznaczać całą sekcję jako kliencką, wydziel tylko element interaktywny:
// Client component
"use client";
export function ExpandButton() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen((value) => !value)}>Toggle</button>;
}// Client component
"use client";
export function ExpandButton() {
const [open, setOpen] = useState(false);
return <button onClick={() => setOpen((value) => !value)}>Toggle</button>;
}Reszta sekcji może pozostać serwerowa. To zwykle daje większy efekt niż mikrooptymalizacje w samym komponencie.
Ładuj ciężkie elementy dopiero wtedy, gdy są potrzebne
Mapy, wykresy, edytory, animacje i rozbudowane formularze nie zawsze muszą być częścią pierwszego ładowania. Jeśli są niżej na stronie albo za interakcją, użyj importu dynamicznego.
import dynamic from "next/dynamic";
const PricingCalculator = dynamic(() => import("./pricing-calculator"), {
loading: () => <div>Ładowanie kalkulatora...</div>,
});import dynamic from "next/dynamic";
const PricingCalculator = dynamic(() => import("./pricing-calculator"), {
loading: () => <div>Ładowanie kalkulatora...</div>,
});Po zmianie sprawdź build. Dynamic import ma sens tylko wtedy, gdy faktycznie zmniejsza główną paczkę albo odsuwa koszt na moment, w którym użytkownik go potrzebuje.
Obrazy: sprawdź LCP, nie zgaduj
Obrazy często są największym zasobem, ale nie każdy obraz wymaga tego samego traktowania. Najpierw sprawdź, który element jest LCP. Jeżeli to hero, ustaw wymiary, sensowny format i priorytet. Jeżeli to obraz niżej na stronie, priorytet może zaszkodzić.
import Image from "next/image";
<Image
src={hero.url}
alt={hero.alt}
width={1600}
height={900}
priority
/>;import Image from "next/image";
<Image
src={hero.url}
alt={hero.alt}
width={1600}
height={900}
priority
/>;Nie ustawiaj priority na wielu obrazach jednocześnie. Przeglądarka nie może traktować wszystkiego jako najważniejsze.
Skrypty zewnętrzne też są częścią wydajności
Analityka, widgety, chaty, consent manager i piksele reklamowe potrafią zepsuć wynik bardziej niż kod aplikacji. W Next.js użyj strategii ładowania, która pasuje do znaczenia skryptu.
import Script from "next/script";
<Script
src="https://example.com/widget.js"
strategy="afterInteractive"
/>;import Script from "next/script";
<Script
src="https://example.com/widget.js"
strategy="afterInteractive"
/>;Jeżeli skrypt nie jest potrzebny do pierwszej interakcji, rozważ późniejsze ładowanie albo uruchomienie dopiero po zgodzie użytkownika.
Sprawdzaj efekt każdej zmiany
Dobra optymalizacja kończy się porównaniem. Po zmianie zapisz:
rozmiar route bundle przed i po,
czas najwolniejszych zapytań,
wynik LCP/TBT/CLS dla tej samej trasy,
liczbę requestów i największe zasoby,
zachowanie w draft mode albo preview, jeśli trasa korzysta z CMS.
Najgorszy scenariusz to kilka zmian naraz i jeden wynik końcowy. Wtedy nie wiesz, co pomogło, co zaszkodziło, a co było neutralne.
Praktyczna kolejność działań
Jeżeli nie wiesz, od czego zacząć, użyj tej kolejności:
Wybierz jedną trasę i zapisz metryki bazowe.
Sprawdź, czy problem jest po stronie serwera czy przeglądarki.
Usuń oczywiste powtórzone fetchowanie.
Ogranicz granice
use client.Przenieś ciężkie elementy za
Suspensealbo dynamic import.Uporządkuj cache i rewalidację.
Dopiero potem dopieszczaj obrazy i skrypty zewnętrzne.
Szybka aplikacja Next.js to efekt serii małych, potwierdzonych decyzji. Mierz, zmieniaj jedną rzecz i zapisuj wynik. Wtedy optymalizacja przestaje być zgadywaniem, a zaczyna być normalną pracą inżynierską.