Core Web Vitals (CWV) — это стандартизированная система метрик качества пользовательского опыта (User Experience), разработанная Google. Начиная с обновления алгоритмов Page Experience, эти показатели являются прямым фактором поискового ранжирования и бизнес-конверсии.
В современной веб-разработке оценка производительности делится на две категории:
Next.js App Router спроектирован вокруг оптимизации ключевых метрик: серверные компоненты уменьшают клиентский бандл, потоковый рендеринг снижает TTFB (Time to First Byte), а компоненты next/image и next/font устраняют сдвиги верстки. Однако некорректная архитектура клиентских компонентов способна свести эти преимущества к нулю.
Сервер Next.js отправляет первый чанк HTML с метатегами и предварительными ссылками (preload) за считанные миллисекунды.
Браузер считывает preload link для hero-изображения и шрифтов еще до окончания парсинга всего документа.
Место под изображения и шрифты зарезервировано заранее через aspect-ratio и size-adjust. Контент не прыгает.
Server Components устраняют лишний JS из бандла. Обработчики кликов оборачиваются в startTransition, исключая Long Tasks.
Данные о реальном пользовательском опыте собираются через useReportWebVitals и sendBeacon без нагрузки на CPU.
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, // Уникальный 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 ручной сбор метрик заменяется официальным RUM-клиентом @vercel/speed-insights/next. Он подключается в корневой layout.tsx одной строкой:
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
{children}
{/* Автоматически отслеживает LCP, INP, CLS с разбивкой по URL и странам */}
<SpeedInsights />
</body>
</html>
);
}LCP-элемент первого экрана (баннер или заголовок) должен отдаваться непосредственно в серверном 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)#Если клиентский компонент выполняет тяжелую фильтрацию списка при вводе в текстовое поле, прямой синхронный вызов setState порождает 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 рендерится мгновенно (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>
);
}Динамические блоки (реклама, виджеты, загружаемые данные) обязаны резервировать геометрию до завершения сетевого ответа.
import React from 'react';
interface DynamicSlotProps {
children?: React.ReactNode;
isLoading: boolean;
}
export function DynamicSlot({ children, isLoading }: DynamicSlotProps) {
// Фиксированная высота и aspect-ratio гарантируют нулевой 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 секунд)?
Если в app/layout.tsx выполняется тяжелый асинхронный await db.query() или внешний fetch() без обертки в <Suspense>, Next.js не сможет начать потоковую передачу HTML (Streaming). Браузер будет ждать ответа сервера секундами, что приведет к катастрофическому росту TTFB и LCP.
Решение: Выносите динамические запросы во вложенные компоненты, обернутые в границы <Suspense fallback={<HeaderSkeleton />}>, либо используйте Partial Prerendering (PPR) для немедленной отдачи статической оболочки.
Выполнение тяжелой фильтрации больших массивов данных прямо в обработчике onChange или клике кнопки занимает основной поток более чем на 100–300 мс (Long Task). Браузер не может отрисовать следующий кадр (Next Paint), вызывая сбой метрики INP.
Решение: Оборачивайте некритичные обновления стейта в хук startTransition из React 19: startTransition(() => setFilteredItems(...)). Это позволяет браузеру мгновенно отрисовать клик/ввод, не блокируя интерфейс.
Подгрузка рекламных баннеров, виджетов или динамических списков без заранее зарезервированного пространства в DOM сдвигает весь контент страницы вниз в момент завершения загрузки.
Решение: Используйте CSS-свойство aspect-ratio или фиксированные классы высоты min-h-[250px] у контейнеров-оберток вместе со скелетонами загрузки.
Попытка загрузить URL hero-баннера через useEffect заставляет браузер сначала скачать весь JS-бандл, распарсить его, выполнить гидратацию, сделать запрос к API и только потом начать скачивать картинку. Это гарантированно уводит LCP в «красную зону» (более 4 секунд).
Решение: Главное изображение обязано рендериться на сервере в первом чанке HTML с компонентом <Image src="..." priority ... />.
Ленивая загрузка компонентов первого экрана откладывает их появление до момента скачивания и парсинга отдельного JS-чанка. Это искусственно увеличивает задержку отрисовки LCP-элемента, ухудшая оценку производительности на 1–2 секунды.
Решение: Никогда не оборачивайте LCP-компоненты первого экрана в next/dynamic.
Подключение внешних скриптов тегом <script> без атрибутов defer/async блокирует парсинг HTML-дерева до полной загрузки файла по сети.
Решение: Всегда применяйте next/script со стратегией afterInteractive или lazyOnload.