Kamil Owczarek
Opublikowano

Naprawa Core Web Vitals w Nuxt 3: szybkie wygrane w LCP i CLS

Autorzy

Problem: żółte i czerwone Core Web Vitals

Core Web Vitals od Google bezpośrednio wpływają na pozycje w wyszukiwarce. Kiedy sprawdziłem PageSpeed Insights dla naszego sklepu, wyniki nie były najlepsze. LCP było ospałe, a CLS powodowało przeskoki układu strony. Klasyczne problemy z wydajnością, ale poprawki okazały się prostsze, niż się spodziewałem.

Oto co było nie tak i jak to naprawiłem.

Poprawka nr 1: preconnect do zewnętrznych originów

Problem

Nasza strona pobiera zasoby z kilku domen:

  • CDN z obrazkami (deante.b-cdn.net)
  • Widget zgód na cookies (consent.cookiefirst.com)
  • Analityka (www.googletagmanager.com)

Dla każdej nowej domeny przeglądarka musi wykonać:

  1. DNS lookup
  2. Połączenie TCP
  3. Negocjację TLS

To 200-500 ms opóźnienia zanim w ogóle zacznie pobierać zasób.

Rozwiązanie

Dodaj wskazówki preconnect w konfiguracji Nuxta:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        { rel: 'preconnect', href: 'https://your-cdn.net' },
        { rel: 'preconnect', href: 'https://consent.cookiefirst.com' },
        { rel: 'preconnect', href: 'https://www.googletagmanager.com' },
      ],
    },
  },
})

Dlaczego to działa: przeglądarka rozpoczyna handshake od razu przy ładowaniu strony, zanim jeszcze wie, że te zasoby będą jej potrzebne. Kiedy przychodzi właściwy request, połączenie jest już zestawione.

Jak znaleźć swoje zewnętrzne domeny

  1. Otwórz DevTools → zakładka Network
  2. Załaduj stronę
  3. Poszukaj requestów do zewnętrznych domen
  4. Priorytetyzuj domeny serwujące treść widoczną bez scrollowania

Skup się na:

  • CDN z obrazkami hero
  • Krytycznych skryptach zewnętrznych
  • Web fontach

Nie rób preconnecta do wszystkiego — każda wskazówka ma swój koszt. Trzymaj się 3-5 kluczowych originów.

Poprawka nr 2: dodaj width i height do obrazków

Problem

Kiedy obrazki ładują się bez jawnych wymiarów, przeglądarka nie wie, ile miejsca zarezerwować. W miarę ładowania układ strony przeskakuje — i to jest właśnie CLS (Cumulative Layout Shift).

<!-- Bad: No dimensions -->
<img :src="product.image" alt="Product" />

<!-- Also bad: Only width, no height -->
<img :src="product.image" width="300" alt="Product" />

Rozwiązanie

Zawsze podawaj i width, i height:

<CustomNuxtImg
  :src="product.mainPhoto?.fullpath"
  width="300"
  height="300"
  class="aspect-square w-full object-contain"
  :alt="productName"
/>

Nawet jeśli faktycznym rozmiarem sterujesz CSS-em, przeglądarka używa tych atrybutów do wyliczenia proporcji jeszcze przed załadowaniem obrazka.

Prawdziwe przykłady z naszego kodu

Kafelki produktów:

<CustomNuxtImg
  :src="product.mainPhoto?.fullpath"
  width="300"
  height="300"
  class="mx-auto aspect-square w-4/5 object-contain"
  :alt="getProductFullNamePrisma(product, locale)"
/>

Baner hero:

<CustomNuxtImg
  :src="banner.backgroundImage.fullpath"
  width="1380"
  height="450"
  class="h-[450px] w-full rounded-3xl object-cover"
  :alt="null"
  :preload="index === 0"
/>

Logo:

<CustomNuxtImg
  src="/_MARKETING/NA WWW/logo.png"
  width="190"
  height="45"
  class="h-auto w-32 md:w-52"
  :alt="null"
  preload
/>

Siatka kategorii:

<CustomNuxtImg
  v-if="element.photo"
  width="300"
  height="128"
  loading="lazy"
  :src="element.photo.fullpath"
  :placeholder="[512, 256, 60]"
  class="h-32 w-full rounded object-cover"
  :alt="element.name"
/>

Schemat postępowania

  1. Zmierz faktyczne wymiary obrazka
  2. Dodaj atrybuty width i height (nawet jeśli rozmiarem steruje CSS)
  3. Użyj aspect-ratio w CSS, gdy wymiary są proporcjonalne
  4. Połącz z object-cover albo object-contain, żeby dobrze skalować

Poprawka nr 3: preload krytycznych obrazków

Problem

Przeglądarka odkrywa obrazki późno — musi sparsować HTML, potem CSS, a potem ustalić, które obrazki są widoczne. Przy obrazkach hero to opóźnienie zabija LCP.

Rozwiązanie

Użyj propsa preload z Nuxt Image dla obrazków widocznych bez scrollowania:

<CustomNuxtImg
  :src="banner.backgroundImage.fullpath"
  :preload="index === 0"  <!-- Only preload the first banner -->
  width="1380"
  height="450"
/>

To dodaje <link rel="preload"> do head dokumentu, mówiąc przeglądarce, żeby zaczęła pobieranie od razu.

Ważne: preloaduj tylko te obrazki, które na pewno są widoczne przy pierwszym renderze. Nadmiarowy preload marnuje transfer.

Karuzele i slidery

Preloaduj wyłącznie pierwszy slajd:

<template v-for="(item, index) in items">
  <CustomNuxtImg :src="item.image" :preload="index === 0" width="800" height="400" />
</template>

Poprawka nr 4: lazy loading obrazków poniżej pierwszego ekranu

Problem

Ładowanie wszystkich obrazków od razu spowalnia ścieżkę krytyczną.

Rozwiązanie

Dodaj loading="lazy" do obrazków poniżej pierwszego ekranu:

<CustomNuxtImg
  v-if="element.photo"
  width="300"
  height="128"
  loading="lazy"
  :src="element.photo.fullpath"
/>

To mówi przeglądarce, żeby wstrzymała ładowanie, dopóki obrazek nie znajdzie się blisko viewportu.

Czego NIE lazy loadować

  • Obrazków hero
  • Pierwszego banera w karuzeli
  • Logo
  • Ikon nawigacji
  • Czegokolwiek na pierwszym ekranie

Łączny efekt

Po tych zmianach w 5 plikach:

MetrykaPrzedPo
LCP4,2 s2,1 s
CLS0,250,05
Wynik PageSpeed5889

Najwięcej dało:

  1. Preconnect do CDN (LCP w dół o około 1 s)
  2. Wymiary obrazków (CLS blisko zera)
  3. Preload obrazka hero (LCP w dół o około 0,5 s)

Szybka checklista

Dla LCP (Largest Contentful Paint)

  • Dodaj wskazówki preconnect dla CDN i kluczowych domen zewnętrznych
  • Preloaduj obrazki hero/banery propsem preload
  • Upewnij się, że element LCP jest w początkowym HTML-u (a nie doładowywany JS-em)
  • Sprawdź format obrazków (WebP/AVIF zamiast JPEG/PNG)

Dla CLS (Cumulative Layout Shift)

  • Dodaj width i height do WSZYSTKICH obrazków
  • Ustaw jawne wymiary slotów reklamowych
  • Zarezerwuj miejsce na treści dynamiczne
  • Unikaj wstawiania treści nad już wyrenderowaną treścią

Dla FCP/TTI (bonus)

  • Lazy loading obrazków poniżej pierwszego ekranu
  • Placeholder/skeleton pod obrazki
  • Odrocz niekrytyczne skrypty zewnętrzne

Ściąga z Nuxt Image

<!-- Hero image: preload, dimensions, no lazy -->
<CustomNuxtImg src="/hero.jpg" width="1920" height="600" preload :placeholder="[1920, 600, 50]" />

<!-- Product grid: lazy, dimensions, placeholder -->
<CustomNuxtImg
  :src="product.image"
  width="300"
  height="300"
  loading="lazy"
  :placeholder="[300, 300, 30]"
/>

<!-- Logo: preload, small dimensions -->
<CustomNuxtImg src="/logo.svg" width="190" height="45" preload />

Monitorowanie Core Web Vitals

Na developmencie

// plugins/web-vitals.client.ts
import { onCLS, onFCP, onLCP, onTTFB } from 'web-vitals'

export default defineNuxtPlugin(() => {
  if (process.dev) {
    onCLS(console.log)
    onFCP(console.log)
    onLCP(console.log)
    onTTFB(console.log)
  }
})

Na produkcji

  • Google Search Console → raport Core Web Vitals
  • PageSpeed Insights do sprawdzeń na żądanie
  • Rozszerzenie Web Vitals do Chrome dla monitoringu na żywo

Wnioski

Naprawa Core Web Vitals nie musi być skomplikowana:

  1. Preconnect do CDN i kluczowych zewnętrznych domen
  2. Wymiary przy każdym obrazku
  3. Preload obrazków hero
  4. Lazy loading wszystkiego poniżej pierwszego ekranu

Te cztery zmiany zajęły 30 minut i podniosły nasz wynik PageSpeed z 58 na 89. Efekt SEO jest realny — Google wprost używa Core Web Vitals jako czynnika rankingowego.


Prawdziwe poprawki ze sklepu internetowego. 5 zmienionych plików, wynik PageSpeed wyżej o 31 punktów.