Przejdź do treści
PS

18 lipca 2026 ·

Core Web Vitals na stronie marketingowej w Next.js: LCP z 4s do 1,2s


Klient zgłosił się do mnie z marketingową stroną w Next.js, która na moim laptopie wyglądała dobrze, a na telefonie sprawiała wrażenie zepsutej. Liczbą, która miała znaczenie, było Largest Contentful Paint: 4.0s na średniej klasy Androidzie w sieci 4G. Google za "dobry" wynik uznaje próg 2.5s. Strona traciła odwiedzających, których pozyskanie opłacono reklamami, jeszcze zanim wyrenderował się hero.

Oto dokładnie to, co znalazłem, co zmieniłem i jak bardzo każda zmiana faktycznie przesunęła wskaźniki. Nie ma jednego cudownego rozwiązania — LCP to śmierć od tysiąca ran i naprawia się je tak samo.

Najpierw pomiar, w odpowiednich warunkach

Błąd, który widzę najczęściej: ludzie optymalizują pod uruchomienie Lighthouse na desktopie i szybkim łączu, po czym ogłaszają sukces. A to nie tam są użytkownicy.

LCP mierzę na trzy sposoby:

  • Lighthouse w Chrome DevTools, ale z emulacją urządzenia mobilnego oraz presetem "Slow 4G" i throttlingiem CPU 4×. To Twoja powtarzalna liczba laboratoryjna.
  • WebPageTest na profilu prawdziwego urządzenia, bo emulacja zaniża koszt CPU.
  • Dane terenowe z Chrome UX Report (CrUX) — 75. percentyl realnych odwiedzających. Lab mówi, co jest możliwe; teren mówi, co dzieje się naprawdę.

Na tej stronie liczba laboratoryjna wynosiła 4.0s, a terenowe p75 było jeszcze gorsze. Dobrze — obie wskazywały ten sam kierunek.

Znajdź element LCP

Otwórz panel Performance, nagraj wczytanie strony i spójrz na znacznik "LCP". DevTools wskaże Ci dokładny element. Tutaj był to hero <img> — 1.4 MB JPEG renderowany na pełną szerokość viewportu, serwowany w oryginalnym rozmiarze 3000px i nie korzystający z next/image.

Ten jeden fakt tłumaczył większość z tych 4 sekund: ogromny, niezoptymalizowany, niemal blokujący render obraz bez wskazówki priorytetu, bez nowoczesnego formatu i bez ograniczeń szerokości.

Poprawka 1 — obraz hero (ten duży)

Przełączenie hero na next/image z wskazówką priority dało więcej niż wszystko inne razem wzięte:

import Image from "next/image";
import hero from "@/public/hero.jpg";

export function Hero() {
  return (
    <Image
      src={hero}
      alt="..."
      priority
      sizes="100vw"
      className="h-[60vh] w-full object-cover"
      placeholder="blur"
    />
  );
}

Co daje Ci każdy z elementów:

  • priority wczytuje obraz z wyprzedzeniem (preload) i wyłącza go z lazy-loadingu, więc przeglądarka pobiera Twój element LCP natychmiast, zamiast odkrywać go z opóźnieniem.
  • Automatyczny AVIF/WebP zredukował transfer z 1.4 MB do ~180 KB.
  • sizes="100vw" pozwala Next wygenerować źródła o odpowiednich rozmiarach, więc telefon pobiera obraz w rozmiarze na telefon, a nie ten 3000px.
  • placeholder="blur" eliminuje rażące doskakiwanie obrazu (pop-in) i stabilizuje układ.

LCP po tej jednej zmianie: 4.0s → 2.1s.

Poprawka 2 — fonty, które przestają blokować

Strona ładowała dwa web fonty z zewnętrznego CDN za pomocą domyślnego <link>. To żądanie blokujące render na critical path, plus przesunięcie układu w momencie podmiany fontu.

next/font self-hostuje pliki, eliminuje dodatkowe DNS/połączenie i obsługuje za Ciebie font-display:

import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
});

Self-hosting wyeliminował round-trip do zewnętrznego origin, a zachowanie swap sprawiło, że tekst renderował się natychmiast w foncie zastępczym, a potem był podmieniany. Mniejszy zysk na LCP, większy na CLS.

LCP: 2.1s → 1.8s.

Poprawka 3 — wysyłaj mniej JavaScriptu

Sekcja hero była Client Componentem ("use client") z jednego powodu: pojedynczego animowanego licznika daleko poniżej linii zgięcia (below the fold). To wymuszało hydratację całego poddrzewa, zanim się ustabilizowało.

Zamieniłem stronę na Server Component i zepchnąłem "use client" w dół, do jedynego liścia, który faktycznie potrzebował interaktywności. Mniej wysłanego JS, mniej pracy na głównym wątku konkurującej z dekodowaniem obrazu.

Zasada, którą teraz stosuję: komponent jest Server Componentem, dopóki nie udowodnisz inaczej. Interaktywność to sprawa liścia, nie całej strony.

LCP: 1.8s → 1.4s.

Poprawka 4 — zdejmij zewnętrzne skrypty z critical path

Analityka i widget czatu ładowały się zwykłymi tagami <script> w <head>. next/script z odpowiednią strategią odracza je do momentu, w którym nie mogą już zaszkodzić renderowaniu:

import Script from "next/script";

<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />;
  • afterInteractive dla rzeczy, które chcesz mieć szybko, ale nie blokująco (większość analityki).
  • lazyOnload dla wszystkiego, co nieistotne (czat, heatmapy).

Sam widget czatu podkradał ~300ms głównego wątku podczas wczytywania. Odroczenie go nic nie kosztowało.

LCP: 1.4s → 1.2s.

Poprawka 5 — renderuj statycznie tam, gdzie się da

Strona główna pobierała treść z CMS przy każdym żądaniu bez cache'owania, więc każde wejście płaciło za round-trip. Treść tej strony zmienia się może raz na tydzień. Renderowanie statyczne (z rewalidacją według harmonogramu) sprawiło, że powłoka HTML była serwowana z edge natychmiast, a LCP w ogóle przestało zależeć od pobrania z origin.

Przed i po

| Metryka (mobile, Slow 4G) | Przed | Po | | ------------------------ | ------ | ----- | | LCP | 4.0s | 1.2s | | Transfer hero | 1.4 MB | 180 KB | | Całkowity JS | wysoki | ~40% mniej | | CLS | 0.18 | 0.02 |

Co radziłbym sprawdzić w pierwszej kolejności

Jeśli masz godzinę i wolną stronę w Next.js, zrób to w tej kolejności:

  1. Znajdź element LCP w DevTools. Prawie zawsze jest to obraz.
  2. Przepuść go przez next/image z priority i realnym sizes.
  3. Przenieś fonty na next/font.
  4. Usuń jeden "use client" — zepchnij go do liścia, który go potrzebuje.
  5. Przepuść każdy zewnętrzny skrypt przez next/script.

Nic z tego nie jest sprytne. LCP nagradza dyscyplinę, nie sztuczki: serwuj odpowiednio duże bajty dla najważniejszego elementu i nie pozwól, by cokolwiek innego stawało mu na drodze.