Core Web Vitals (CWV) — це стандартизований набір метрик реального користувацького досвіду (User Experience), розроблений компанією Google. Починаючи з оновлення алгоритмів ранжування Page Experience, ці показники безпосередньо впливають на позиції сайту в пошуковій видачі та показники бізнес-конверсії.
У сучасній веб-розробці вимірювання продуктивності поділяється на дві категорії:
Next.js App Router спроєктовано навколо оптимізації ключових метрик: серверні компоненти вилучають зайвий JavaScript з клієнтського бандла, потоковий рендеринг скорочує TTFB (Time to First Byte), а примітиви next/image та next/font запобігають зсувам верстки. Проте неоптимальна архітектура клієнтських компонентів здатна нівелювати всі ці вбудовані переваги.
Сервер Next.js відправляє перший чанк HTML з метатегами та посиланнями попереднього завантаження (preload) за мілісекунди.
Браузер зчитує preload-лінки для головного зображення та критичних шрифтів ще до повного розбору всього документа.
Місце під зображення, шрифти та динамічні слоти резервується заздалегідь через aspect-ratio та налаштування size-adjust.
Серверні компоненти усувають зайвий JS. Важкі обчислення в клієнтських обробниках огортаються в startTransition, усуваючи Long Tasks.
Емпіричні дані телеметрії збираються асинхронно через useReportWebVitals та sendBeacon без блокування основного потоку.
useReportWebVitals#Next.js має вбудований телеметричний хук useReportWebVitals з модуля next/web-vitals, призначений для перехоплення показників продуктивності браузера та передачі їх у власну аналітичну систему.
'use client';
import { useReportWebVitals, type NextWebVitalsMetric } from 'next/web-vitals';
export function WebVitalsReporter() {
useReportWebVitals((metric: NextWebVitalsMetric) => {
// Структурований об'єкт телеметрії
const payload = JSON.stringify({
id: metric.id, // Унікальний ідентифікатор вимірювання для дедуплікації
name: metric.name, // 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: Math.round(metric.delta),
navigationType: metric.navigationType, // 'navigate' | 'reload' | 'back-forward'
});
const endpoint = '/api/analytics/vitals';
// navigator.sendBeacon гарантує доставку навіть під час закриття вкладки
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
navigator.sendBeacon(endpoint, payload);
} else {
fetch(endpoint, {
body: payload,
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
});
return null;
}Під час розгортання на платформі Vercel ручний збір метрик можна доповнити або замінити офіційним пакетом @vercel/speed-insights/next. Він підключається в кореневий layout.tsx без додаткового налаштування:
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="uk">
<body>
{children}
{/* Автоматично відстежує LCP, INP, CLS з розбивкою за маршрутами та географією */}
<SpeedInsights />
</body>
</html>
);
}Головний елемент першого екрана (hero-банер або заголовок H1) повинен віддаватися безпосередньо в серверному HTML без очікування завантаження клієнтських бандлів або ланцюжка мережевих запитів.
import Image from 'next/image';
interface HeroBannerProps {
title: string;
imageSrc: string;
}
export function HeroBanner({ title, imageSrc }: HeroBannerProps) {
return (
<section className="relative w-full h-[400px] md:h-[560px] overflow-hidden rounded-2xl bg-bg-surface">
<Image
src={imageSrc}
alt={title}
fill
sizes="(max-width: 768px) 100vw, 1200px"
// priority додає <link rel="preload"> у <head> HTML-документа
priority
className="object-cover"
quality={80}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent flex items-end p-8">
<h1 className="text-3xl md:text-5xl font-extrabold text-white tracking-tight">
{title}
</h1>
</div>
</section>
);
}Під час фільтрації каталогу на 5000 товарів користувач вводить текст у поле пошуку. Основний потік браузера блокується на 180 мс синхронним циклом JavaScript. Чому це погіршує INP і як React 19 вирішує цю проблему?
startTransition)#Коли клієнтський компонент виконує важку фільтрацію або сортування при введенні тексту, пряме синхронне оновлення стану викликає Long Task (> 50 мс), заморожуючи екран.
'use client';
import React, { useState, useTransition } from 'react';
interface SearchFilterProps {
items: string[];
}
export function SearchFilter({ items }: SearchFilterProps) {
const [query, setQuery] = useState('');
const [filteredList, setFilteredList] = useState(items);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// 1. Термінове оновлення: текст у полі введення оновлюється миттєво (Input Delay = 0)
setQuery(value);
// 2. Нетерміновий перехід: обчислювальна фільтрація виконується у фоновій черзі React 19
startTransition(() => {
const filtered = items.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
);
setFilteredList(filtered);
});
};
return (
<div className="space-y-4">
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Пошук уроків..."
className="w-full px-4 py-2 rounded-lg border border-border bg-bg-surface text-text-primary"
/>
{isPending && <div className="text-xs text-text-muted animate-pulse">Оновлення результатів...</div>}
<ul className="divide-y divide-border">
{filteredList.map((item, idx) => (
<li key={idx} className="py-2 text-sm text-text-primary">{item}</li>
))}
</ul>
</div>
);
}Динамічні елементи інтерфейсу (банери, віджети, списки даних, що підвантажуються) зобов'язані резервувати фізичні габарити в DOM до прибуття відповіді від мережі.
import React from 'react';
interface DynamicSlotProps {
children?: React.ReactNode;
isLoading: boolean;
}
export function DynamicSlot({ children, isLoading }: DynamicSlotProps) {
// Фіксовані aspect-ratio та min-height гарантують нульовий CLS
return (
<div className="w-full min-h-[250px] aspect-video md:aspect-[21/9] rounded-xl border border-border bg-bg-surface overflow-hidden relative">
{isLoading ? (
<div className="absolute inset-0 animate-pulse bg-bg-elevated flex items-center justify-center">
<span className="text-xs text-text-muted font-mono">Завантаження контенту...</span>
</div>
) : (
children
)}
</div>
);
}Команда розробників огорнула компонент головного банера першого екрана в next/dynamic({ ssr: false }), а саму картинку завантажує через useEffect() на клієнті. Чому показник LCP опускається в «червону зону» (> 4.0 секунд)?
Виконання важкого асинхронного await db.query() або некешованого fetch() в app/layout.tsx без Suspense-меж блокує потокову віддачу HTML у Next.js. Браузер простоює в очікуванні сервера, критично погіршуючи показники TTFB та LCP.
Рішення: Виносьте динамічні запити у вкладені компоненти, огорнуті в <Suspense fallback={<HeaderSkeleton />}>, або застосовуйте Partial Prerendering (PPR) для негайної віддачі статичної оболонки.
Виконання складної фільтрації великих масивів даних безпосередньо в обробниках onChange або кліку блокує основний потік на 100–300 мс. Браузер не здатний відмалювати наступний кадр, що призводить до падіння INP.
Рішення: Огортайте нетермінові переходи стану за допомогою startTransition з React 19: startTransition(() => setFilteredItems(...)).
Вставка рекламних банерів, віджетів або списків зі змінною висотою без попереднього резервування місця в DOM зміщує весь контент униз після завантаження.
Рішення: Використовуйте CSS-властивість aspect-ratio або фіксовану висоту контейнерів (min-h-[250px]) разом зі скелетонами завантаження.
Завантаження адреси головного банера через useEffect змушує браузер спочатку завантажити клієнтські бандли, провести гідратацію і лише потім відправити запит за картинкою.
Рішення: Рендерите критичні зображення першого екрана безпосередньо в серверних компонентах за допомогою <Image src="..." priority ... />.
Застосування динамічного розділення коду (next/dynamic({ ssr: false })) до головного банера відкладає його появу до моменту завантаження та гідратації окремого чанка.
Рішення: Ніколи не застосовуйте відкладене завантаження до компонентів, що містять головний LCP-асет.
Підключення зовнішніх аналітичних або маркетингових бібліотек звичайним тегом <script> без атрибутів defer або async повністю зупиняє розбір HTML-дерева.
Рішення: Завжди використовуйте next/script із параметрами strategy="afterInteractive" або strategy="lazyOnload".