Jak przyspieszyć aplikację Next.js bez zgadywania

MJ

Mateusz JanotaCEO & Founder

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.

bash
pnpm build
pnpm check

Wynik 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:

bash
ANALYZE=true pnpm build

Nie 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:

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

tsx
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:

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

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

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

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

tsx
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:

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

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

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

tsx
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:

  1. Wybierz jedną trasę i zapisz metryki bazowe.

  2. Sprawdź, czy problem jest po stronie serwera czy przeglądarki.

  3. Usuń oczywiste powtórzone fetchowanie.

  4. Ogranicz granice use client.

  5. Przenieś ciężkie elementy za Suspense albo dynamic import.

  6. Uporządkuj cache i rewalidację.

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

Masz projekt w głowie?

Napisz do nas

Porozmawiajmy o tym, jak możemy pomóc w realizacji Twoich pomysłów.

Zanek

Nie nadążasz za zmianami w świecie AI?

Pozwól, że weźmiemy to na siebie. Co tydzień destylujemy najważniejsze wydarzenia ze świata AI w skupiony, 5-minutowy przegląd - żebyś był na bieżąco bez szumu.

Dowiedz się więcej
Tygodnik AIonline
Wyselekcjonowane wiadomości AI do porannej kawy. Co tydzień.
Wyślij mi swój email, żeby się zapisać.