Пошук уроків, статей та іншого контенту
Навчитеся вимірювати Core Web Vitals і оптимізувати LCP, INP та CLS у застосунках Next.js.
Core Web Vitals — набір польових метрик, які описують реальний досвід користувача під час завантаження та взаємодії зі сторінкою.
У цьому уроці розглянемо три основні метрики:
LCP (Largest Contentful Paint) — наскільки швидко відображається найбільший видимий елемент сторінки.
INP (Interaction to Next Paint) — наскільки швидко сторінка реагує на взаємодії користувача.
CLS (Cumulative Layout Shift) — наскільки сильно зміщується вміст сторінки під час завантаження.
Метрики вимірюються на стороні користувача, тому результат залежить від:
типу пристрою;
швидкості мережі;
браузера;
розміру сторінки;
стану кешу;
поведінки користувача.
| Метрика | Добре | Потрібне покращення | Погано | |---|---:|---:|---:| | LCP | до 2,5 с | 2,5–4 с | понад 4 с | | INP | до 200 мс | 200–500 мс | понад 500 мс | | CLS | до 0,1 | 0,1–0,25 | понад 0,25 |
Оцінювання зазвичай виконується за 75-м перцентилем значень. Це означає, що сторінка має відповідати порогу щонайменше для 75% вимірювань у відповідному сегменті користувачів.
Не слід змішувати:
лабораторні дані — результат контрольованого запуску Lighthouse або DevTools;
польові дані — реальні вимірювання користувачів у продакшені.
Лабораторний запуск допомагає знайти причину проблеми. Польові дані показують, чи справді зміна покращила досвід користувачів.
У Next.js можна отримувати метрики через useReportWebVitals. Для App Router це роблять у клієнтському компоненті.
Структура файлів:
app/
layout.tsx
web-vitals.tsx
api/
vitals/
route.tsФайл app/web-vitals.tsx:
'use client';
import { useReportWebVitals } from 'next/web-vitals';
type VitalMetric = {
id: string;
name: string;
value: number;
rating?: string;
navigationType?: string;
};
export function WebVitals() {
useReportWebVitals((metric: VitalMetric) => {
if (!['LCP', 'INP', 'CLS'].includes(metric.name)) {
return;
}
const payload = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
url: window.location.pathname,
userAgent: navigator.userAgent,
});
if (navigator.sendBeacon) {
navigator.sendBeacon(
'/api/vitals',
new Blob([payload], { type: 'application/json' }),
);
} else {
void fetch('/api/vitals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: payload,
keepalive: true,
});
}
});
return null;
}Підключення у app/layout.tsx:
import type { ReactNode } from 'react';
import { WebVitals } from './web-vitals';
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
return (
<html lang="uk">
<body>
{children}
<WebVitals />
</body>
</html>
);
}Обробник маршруту app/api/vitals/route.ts:
export async function POST(request: Request) {
try {
const metric = await request.json();
if (
typeof metric.name !== 'string' ||
!['LCP', 'INP', 'CLS'].includes(metric.name) ||
typeof metric.value !== 'number'
) {
return new Response('Invalid metric', { status: 400 });
}
console.info('Web Vital:', {
name: metric.name,
value: metric.value,
id: metric.id,
url: metric.url,
});
return new Response(null, { status: 204 });
} catch {
return new Response('Invalid JSON', { status: 400 });
}
}У реальному застосунку замість console.info слід передавати значення до системи аналітики або сховища метрик.
Важливо:
не надсилайте персональні дані разом із метриками;
нормалізуйте URL, щоб динамічні ідентифікатори не створювали тисячі різних маршрутів;
зберігайте тип пристрою, регіон і тип з’єднання лише тоді, коли це необхідно;
розраховуйте перцентилі на сервері, а не робіть висновки з одного вимірювання.
Якщо застосунок використовує Pages Router, метрики можна отримувати через reportWebVitals у pages/_app.tsx:
import type { AppProps, NextWebVitalsMetric } from 'next/app';
export function reportWebVitals(metric: NextWebVitalsMetric) {
if (!['LCP', 'INP', 'CLS'].includes(metric.name)) {
return;
}
void fetch('/api/vitals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
path: window.location.pathname,
}),
keepalive: true,
});
}
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}Обирайте один спосіб інтеграції відповідно до маршрутизатора застосунку. Не потрібно одночасно підключати обидва варіанти.
LCP вимірює момент, коли найбільший елемент у видимій області стає доступним користувачу. Найчастіше ним є:
головне зображення;
заголовок;
великий текстовий блок;
постер відео;
банер або hero-секція.
Поганий LCP не обов’язково означає, що повільно завантажується саме зображення. На результат можуть впливати:
час відповіді сервера;
завантаження HTML;
завантаження CSS;
очікування шрифтів;
завантаження ресурсу LCP;
клієнтський JavaScript;
рендеринг елемента після гідратації.
LCP не може бути хорошим, якщо сервер довго починає віддавати HTML.
У Next.js перевірте:
чи не виконується повільний запит до бази даних у серверному компоненті;
чи потрібен запит саме для першого рендера;
чи правильно використовується кешування;
чи не виконується непотрібна логіка перед відправленням першого байта;
чи не блокує сторінку зовнішній API.
Якщо дані не змінюються на кожен запит, їх можна кешувати на рівні Next.js:
async function getFeaturedProduct() {
const response = await fetch('https://example.com/api/featured-product', {
next: {
revalidate: 60,
},
});
if (!response.ok) {
throw new Error('Не вдалося завантажити товар');
}
return response.json() as Promise<{
title: string;
description: string;
}>;
}
export default async function HomePage() {
const product = await getFeaturedProduct();
return (
<main>
<h1>{product.title}</h1>
<p>{product.description}</p>
</main>
);
}Кешування не слід додавати механічно. Якщо дані персоналізовані або мають бути актуальними для кожного запиту, спочатку визначте правильну стратегію отримання даних.
Для зображень використовуйте next/image. Він допомагає генерувати відповідний розмір, сучасний формат і коректний srcset.
import Image from 'next/image';
export function Hero() {
return (
<section className="hero">
<div className="hero__content">
<p className="hero__eyebrow">Платформа для розробників</p>
<h1>Створюйте швидкі вебзастосунки</h1>
<p>
Практичні матеріали для роботи з сучасним стеком JavaScript.
</p>
</div>
<Image
src="/images/hero.webp"
alt="Розробник працює над вебзастосунком"
width={1280}
height={720}
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
</section>
);
}Для LCP-зображення важливі такі властивості:
width і height дають браузеру змогу заздалегідь зарезервувати місце;
sizes не дозволяє завантажувати десктопне зображення на маленькому екрані;
priority повідомляє Next.js, що ресурс важливий для початкового відображення;
alt потрібен для доступності, але сам по собі не впливає на LCP.
priority слід використовувати лише для справді важливого зображення, зазвичай одного головного елемента сторінки. Не позначайте так усі зображення.
У версіях Next.js, де для цього використовується властивість preload, дотримуйтеся API своєї версії. Головний принцип той самий: пріоритезувати лише ресурс, який є LCP-елементом.
Не робіть hero-секцію клієнтським компонентом без необхідності:
'use client';Директива use client збільшує обсяг JavaScript і додає гідратацію. Якщо секція не потребує стану, обробників подій або браузерських API, залиште її серверним компонентом.
Поганий підхід:
'use client';
export function Hero({ title }: { title: string }) {
return <h1>{title}</h1>;
}Якщо компонент лише відображає дані, краще не робити його клієнтським:
export function Hero({ title }: { title: string }) {
return <h1>{title}</h1>;
}Так HTML може бути сформований на сервері без очікування гідратації.
Шрифти можуть затримувати відображення тексту або спричиняти його заміну після завантаження. У Next.js використовуйте next/font, щоб шрифти оброблялися під час збірки.
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin', 'cyrillic'],
display: 'swap',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="uk" className={inter.className}>
<body>{children}</body>
</html>
);
}Завантажуйте лише необхідні:
накреслення;
ваги;
набори символів.
Надмірна кількість варіантів шрифту збільшує розмір сторінки та може погіршити LCP.
INP вимірює затримку між взаємодією користувача і наступним оновленням відображення. Він охоплює взаємодії протягом усього життєвого циклу сторінки:
натискання кнопки;
введення в поле;
перемикання вкладки;
відкриття меню;
встановлення фільтра;
вибір елемента списку.
INP часто погіршується через довгі завдання головного потоку. JavaScript блокує головний потік, тому браузер не може вчасно обробити подію або перемалювати інтерфейс.
великий клієнтський бандл;
складні синхронні обчислення в обробнику;
повторний рендеринг великого дерева React;
обробка кожного натискання клавіші через дорогий пошук;
надмірна кількість стану в одному клієнтському компоненті;
синхронна обробка великих масивів;
непотрібні оновлення батьківського компонента.
Серверні компоненти не додають свій JavaScript до клієнтського бандла. Тому інтерактивність варто локалізувати в невеликих клієнтських компонентах.
Наприклад, сторінка може залишатися серверною, а фільтр — бути окремим клієнтським компонентом:
// app/products/page.tsx
import { ProductFilter } from './product-filter';
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Товари</h1>
<ProductFilter products={products} />
</main>
);
}
async function getProducts() {
return [
{ id: 1, name: 'Клавіатура', category: 'hardware' },
{ id: 2, name: 'Монітор', category: 'hardware' },
{ id: 3, name: 'Курс Next.js', category: 'course' },
];
}// app/products/product-filter.tsx
'use client';
import { useMemo, useState, useTransition } from 'react';
type Product = {
id: number;
name: string;
category: string;
};
export function ProductFilter({ products }: { products: Product[] }) {
const [category, setCategory] = useState('all');
const [isPending, startTransition] = useTransition();
const visibleProducts = useMemo(() => {
if (category === 'all') {
return products;
}
return products.filter((product) => product.category === category);
}, [category, products]);
function handleCategoryChange(nextCategory: string) {
startTransition(() => {
setCategory(nextCategory);
});
}
return (
<section>
<label>
Категорія
<select
value={category}
onChange={(event) => handleCategoryChange(event.target.value)}
>
<option value="all">Усі</option>
<option value="hardware">Обладнання</option>
<option value="course">Курси</option>
</select>
</label>
{isPending && <p>Оновлення списку…</p>}
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</section>
);
}useTransition не робить обчислення швидшим. Він дозволяє React позначити оновлення як непершочергове, щоб інтерфейс міг швидше відреагувати на взаємодію.
Не використовуйте його як заміну оптимізації. Якщо обробник виконує важку синхронну роботу, її все одно потрібно:
розділити на менші частини;
відкласти;
винести з обробника;
зменшити обсяг даних;
оптимізувати рендеринг.
Перевіряйте:
чи змінюються пропси без потреби;
чи створюються великі масиви під час кожного рендера;
чи не оновлюється стан на рівні всієї сторінки;
чи не передається нестабільний callback у багато дочірніх компонентів;
чи не рендеряться сотні невидимих елементів.
Для великих списків важливо не лише оптимізувати React-компонент, а й зменшити кількість DOM-вузлів. Пагінація, фільтрація на сервері або віртуалізація списку часто дають більший ефект, ніж додавання memo.
Важкі інтерактивні модулі, які не потрібні на першому екрані, можна завантажувати окремо:
'use client';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./chart'), {
loading: () => <p>Завантаження графіка…</p>,
});
export function AnalyticsPanel() {
return (
<section>
<h2>Аналітика</h2>
<Chart />
</section>
);
}Це корисно для редакторів, графіків і складних віджетів. Але не відкладайте компонент, який є частиною основної взаємодії або першого екрана: користувач тоді відчує затримку під час першого кліку.
CLS виникає, коли вже видимий контент раптово змінює своє положення. Приклади:
зображення завантажилося без зарезервованої висоти;
шрифт змінив ширину тексту;
зверху з’явився банер;
рекламний блок отримав розмір після завантаження;
клієнтський компонент вставив контент перед уже відображеним текстом.
Використовуйте явні розміри:
import Image from 'next/image';
export function ArticleCover() {
return (
<Image
src="/images/article-cover.webp"
alt="Обкладинка статті"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 800px"
/>
);
}Якщо потрібне зображення, яке заповнює контейнер, контейнер також повинен мати стабільну геометрію:
import Image from 'next/image';
export function CardCover() {
return (
<div className="card-cover">
<Image
src="/images/card-cover.webp"
alt="Ілюстрація картки"
fill
sizes="(max-width: 768px) 100vw, 33vw"
style={{ objectFit: 'cover' }}
/>
</div>
);
}.card-cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
}fill без позиціонованого контейнера та без заданого співвідношення сторін часто призводить до некоректного розміру або зміщення макета.
Якщо компонент завантажує дані на клієнті, його стан завантаження має мати приблизно такий самий розмір, як і фінальний контент:
export function ProfileSkeleton() {
return (
<div
className="profile-skeleton"
aria-busy="true"
aria-label="Завантаження профілю"
/>
);
}.profile-skeleton {
width: 100%;
min-height: 180px;
border-radius: 12px;
background: #e5e7eb;
}Не вставляйте блок над уже видимим контентом після завантаження сторінки. Якщо банер обов’язковий, його місце потрібно зарезервувати одразу.
next/font зменшує ризик зміни шрифту після першого рендера. Додатково:
не завантажуйте багато різних шрифтів;
обирайте коректні fallback-шрифти;
перевіряйте довгі заголовки на малих екранах;
не змінюйте розмір тексту після гідратації без необхідності.
Для анімацій, які не повинні змінювати розкладку, використовуйте transform і opacity, а не top, left, width або height.
.menu {
transform: translateY(-8px);
opacity: 0;
transition:
transform 160ms ease,
opacity 160ms ease;
}
.menu[data-open='true'] {
transform: translateY(0);
opacity: 1;
}Такі властивості зазвичай не змушують браузер перераховувати геометрію всього документа.
Не оцінюйте застосунок одним середнім числом. Розділяйте результати за:
маршрутом;
типом сторінки;
мобільним і десктопним пристроєм;
типом навігації;
регіоном;
новими та повторними відвідувачами.
Проблема LCP на сторінці каталогу може мати зовсім іншу причину, ніж проблема LCP на сторінці статті.
Режим розробки не є надійним середовищем для оцінювання продуктивності. Використовуйте продакшен-збірку:
npm run build
npm run startПід час перевірки:
вимкніть розширення браузера;
зафіксуйте розмір вікна;
повторіть тест кілька разів;
перевірте мобільний профіль;
порівняйте результат із польовими даними.
У DevTools можна визначити, який саме елемент став LCP. Після цього поставте питання:
чому він з’являється пізно;
чи залежить він від клієнтської гідратації;
чи блокує його CSS або шрифт;
чи має зображення правильний пріоритет;
чи не виконується перед ним повільний серверний запит.
Оптимізувати потрібно конкретний LCP-елемент, а не абстрактно «зменшувати JavaScript».
Для INP перегляньте запис продуктивності:
яка взаємодія була повільною;
скільки часу зайняв обробник;
скільки часу пішло на стиль і layout;
який React-компонент повторно відрендерився;
чи не виконується велика робота синхронно після події.
Для CLS перегляньте всі layout shift events і визначте:
який елемент змістився;
що спричинило зміну його розміру;
чи був зарезервований простір;
чи завантажився шрифт із іншими метриками;
чи вставив JavaScript новий блок у документ.
Після релізу не робіть висновків за першим тестом. Польові метрики потребують достатньої кількості сеансів.
Порівнюйте:
p75 до і після зміни;
частку користувачів у категорії «добре»;
окремі маршрути;
мобільні та десктопні пристрої;
помилки й регресії.
Оптимізація вважається успішною не тоді, коли один запуск Lighthouse показав кращий результат, а коли покращилися реальні дані без погіршення інших сценаріїв.
Потужний ноутбук у швидкій мережі приховує проблеми мобільних користувачів. Тестуйте обмежені CPU та мережу, а польові дані сегментуйте за пристроями.
priority для всіх зображеньЦе створює конкуренцію за мережу. Пріоритет має отримувати лише ресурс, необхідний для першого екрана.
use client до всієї сторінкиТак збільшується клієнтський бандл і обсяг гідратації. Залишайте серверними всі компоненти, яким не потрібна інтерактивність.
sizes у адаптивних зображеньБез sizes браузер може вибрати більший ресурс, ніж потрібно для поточної ширини. Це збільшує час завантаження та погіршує LCP.
Додавання довільного margin може приховати одну проблему, але створити іншу. Потрібно зарезервувати точний або передбачуваний простір для зображення, шрифту чи асинхронного блоку.
useMemo і memoЦі інструменти не замінюють аналіз профілювання. Спочатку знайдіть дорогий рендеринг або обчислення, а потім оптимізуйте саме вузьке місце.
Окреме значення LCP, INP або CLS не описує якість застосунку. Використовуйте розподіл значень і щонайменше 75-й перцентиль.
Lighthouse корисний для діагностики, але не показує повну картину реальних пристроїв і мереж. Поєднуйте лабораторні тести з власним RUM-вимірюванням.
LCP залежить від TTFB, критичного HTML, CSS, шрифтів і ресурсу найбільшого елемента.
Для LCP-зображень використовуйте next/image, коректні розміри, sizes і вибіркове пріоритезування.
Не робіть статичний контент клієнтським без потреби.
INP погіршується через довгі завдання, великі клієнтські бандли та надмірні повторні рендеринги.
Локалізуйте інтерактивність, зменшуйте обсяг даних і відкладайте некритичні модулі.
CLS виникає через непередбачувані зміни геометрії.
Завжди резервуйте місце для зображень, шрифтів, рекламних блоків і асинхронного контенту.
Вимірюйте Core Web Vitals у продакшені та передавайте польові дані через useReportWebVitals або reportWebVitals.
Орієнтуйтеся на p75 і аналізуйте результати окремо для маршрутів, пристроїв і типів користувачів.