Оптимизация Core Web Vitals в Next.js

Практические техники улучшения LCP, FID/INP и CLS. Диагностика, оптимизация изображений, шрифтов и JavaScript.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 11 сентября 2026 г.📖 9 мин чтения

Core Web Vitals — редкий случай, когда у «сделать быстрее» есть три конкретных числа вместо ощущений. Но с этими числами связана и главная ошибка подхода: их начинают оптимизировать ради самих чисел, разгоняя лабораторный тест до зелёной зоны при том, что реальным пользователям быстрее не становится.

Разберём метрики, инструменты и приёмы — а начнём с того, какие данные считать настоящими.

💡 Совет

Лабораторные и полевые данные — не одно и то же. Lighthouse и вкладка Performance в браузере показывают лабораторные измерения: один прогон, ваш канал, ваше устройство. Google ранжирует по полевым данным (CrUX) — это реальные посетители с их телефонами и мобильным интернетом.

Расхождение бывает драматическим: сотня баллов в Lighthouse и красная зона в Search Console одновременно — обычная ситуация. Ориентируйтесь на отчёт в Search Console и на собственный сбор метрик с реальных пользователей; Lighthouse полезен для поиска причин, а не для оценки результата.

Что такое Core Web Vitals

Google использует три метрики для оценки пользовательского опыта:

МетрикаЧто измеряетХорошоПлохо
LCPВремя загрузки главного контента< 2.5s> 4s
INPЗадержка при взаимодействии< 200ms> 500ms
CLSВизуальная стабильность< 0.1> 0.25
💡 Совет

Core Web Vitals — это фактор ранжирования в Google. Плохие показатели = ниже в поиске.

Диагностика

Инструменты

  1. Lighthouse — встроен в Chrome DevTools (F12 → Lighthouse)
  2. PageSpeed Insights — pagespeed.web.dev
  3. Web Vitals Extension — реальные данные при просмотре
  4. Next.js Analytics — next/web-vitals для сбора метрик

Сбор метрик в Next.js

tsx
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />
      </body>
    </html>
  );
}

Или кастомный сбор:

tsx
// app/components/WebVitals.tsx
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Отправляем в аналитику
    console.log(metric.name, metric.value);
    
    // Например, в Google Analytics
    window.gtag?.('event', metric.name, {
      value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
      event_label: metric.id,
      non_interaction: true,
    });
  });
  
  return null;
}

Оптимизация LCP

LCP (Largest Contentful Paint) — время до отрисовки главного элемента страницы.

1. Оптимизация изображений

Используйте next/image:

tsx
import Image from 'next/image';

// ❌ Плохо
<img src="/hero.jpg" alt="Hero" />

// ✅ Хорошо
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority // Для LCP-изображений
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Настройка форматов в next.config.ts:

typescript
// next.config.ts
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

export default nextConfig;

2. Preload критических ресурсов

tsx
// app/layout.tsx
import { Metadata } from 'next';

export const metadata: Metadata = {
  // Preload LCP-изображения
  other: {
    'link': [
      {
        rel: 'preload',
        href: '/hero.webp',
        as: 'image',
        type: 'image/webp',
      },
    ],
  },
};

Или через компонент:

tsx
// app/page.tsx
export default function Home() {
  return (
    <>
      <link
        rel="preload"
        href="/hero.webp"
        as="image"
        type="image/webp"
      />
      <main>
        <Image src="/hero.webp" priority ... />
      </main>
    </>
  );
}

3. Оптимизация шрифтов

tsx
// app/layout.tsx
import { Inter } from 'next/font/google';

// Автоматический preload и оптимизация
const inter = Inter({
  subsets: ['latin', 'cyrillic'],
  display: 'swap', // Показывать fallback пока грузится
  preload: true,
  variable: '--font-inter',
});

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Локальные шрифты:

tsx
import localFont from 'next/font/local';

const customFont = localFont({
  src: [
    { path: './fonts/CustomFont-Regular.woff2', weight: '400' },
    { path: './fonts/CustomFont-Bold.woff2', weight: '700' },
  ],
  display: 'swap',
});

4. Минимизируйте блокирующий CSS

tsx
// app/layout.tsx

// ✅ Критический CSS инлайнится автоматически в production
// ✅ Некритический CSS загружается асинхронно

// Для ручного контроля:
import './critical.css'; // Инлайнится
import dynamic from 'next/dynamic';

// Некритические компоненты
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <div>Loading...</div>,
});

Оптимизация INP

INP (Interaction to Next Paint) заменил FID. Измеряет задержку при любом взаимодействии.

1. Разделение JavaScript

tsx
// Ленивая загрузка компонентов
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div className="h-64 animate-pulse bg-gray-200" />,
  ssr: false, // Если компонент не нужен для SEO
});

const Modal = dynamic(() => import('@/components/Modal'));

2. Оптимизация обработчиков

tsx
'use client';

import { useCallback, useTransition } from 'react';

function SearchResults() {
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = useCallback((query: string) => {
    // Не блокируем UI
    startTransition(() => {
      // Тяжёлая операция
      performSearch(query);
    });
  }, []);
  
  return (
    <div>
      <input onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <span>Поиск...</span>}
    </div>
  );
}

3. Используйте Server Components

tsx
// Вместо клиентского fetch
// ❌
'use client';
function Products() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    fetch('/api/products').then(r => r.json()).then(setProducts);
  }, []);
  // ...
}

// ✅ Server Component — нет JavaScript на клиенте
async function Products() {
  const products = await fetch('https://api.example.com/products');
  return <ProductList products={products} />;
}

4. Web Workers для тяжёлых вычислений

tsx
// workers/process.ts
self.onmessage = (e) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

// components/HeavyProcessor.tsx
'use client';

import { useEffect, useRef, useState } from 'react';

export function HeavyProcessor({ data }) {
  const workerRef = useRef<Worker>();
  const [result, setResult] = useState(null);
  
  useEffect(() => {
    workerRef.current = new Worker(
      new URL('../workers/process.ts', import.meta.url)
    );
    
    workerRef.current.onmessage = (e) => setResult(e.data);
    
    return () => workerRef.current?.terminate();
  }, []);
  
  const process = () => {
    workerRef.current?.postMessage(data);
  };
  
  return <button onClick={process}>Обработать</button>;
}

Оптимизация CLS

CLS (Cumulative Layout Shift) — сдвиги контента при загрузке.

1. Указывайте размеры

tsx
// ❌ Плохо — размер неизвестен до загрузки
<img src="/photo.jpg" alt="" />

// ✅ Хорошо — место зарезервировано
<Image
  src="/photo.jpg"
  alt=""
  width={800}
  height={600}
/>

// ✅ Адаптивное с соотношением сторон
<div className="relative aspect-video">
  <Image src="/photo.jpg" alt="" fill className="object-cover" />
</div>

2. Резервируйте место для динамического контента

tsx
// Скелетон с фиксированной высотой
function ArticleSkeleton() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-3/4 bg-gray-200 rounded animate-pulse" />
      <div className="h-4 w-full bg-gray-200 rounded animate-pulse" />
      <div className="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
    </div>
  );
}

// Suspense с тем же размером
<Suspense fallback={<ArticleSkeleton />}>
  <Article />
</Suspense>

3. Шрифты без сдвигов

tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin', 'cyrillic'],
  display: 'swap',
  // Метрики для точного fallback
  adjustFontFallback: true,
});

4. Реклама и embeds

tsx
// Резервируем место под рекламу
<div className="min-h-[250px] bg-gray-100">
  <AdComponent />
</div>

// Iframe с фиксированным aspect ratio
<div className="relative aspect-video">
  <iframe
    src="https://www.youtube.com/embed/..."
    className="absolute inset-0 w-full h-full"
    loading="lazy"
  />
</div>

Продвинутые техники

Partial Prerendering (PPR)

Next.js 14+ позволяет комбинировать статику и динамику:

tsx
// next.config.ts
const nextConfig = {
  experimental: {
    ppr: true,
  },
};

// app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      {/* Статическая часть — мгновенная загрузка */}
      <Header />
      <Hero />
      
      {/* Динамическая часть — стримится */}
      <Suspense fallback={<ProductsSkeleton />}>
        <Products />
      </Suspense>
    </main>
  );
}

Route Segment Config

tsx
// app/products/page.tsx

// Принудительно статическая страница
export const dynamic = 'force-static';

// Или динамическая
export const dynamic = 'force-dynamic';

// Revalidation
export const revalidate = 3600;

Streaming с loading.tsx

tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[1, 2, 3].map((i) => (
        <div 
          key={i} 
          className="h-32 bg-gray-200 rounded animate-pulse" 
        />
      ))}
    </div>
  );
}

Чеклист оптимизации

LCP (< 2.5s)

  • priority на LCP-изображении
  • Preload критических ресурсов
  • next/font для шрифтов
  • Минимизация CSS в critical path
  • CDN для статики

INP (< 200ms)

  • Server Components где возможно
  • dynamic() для тяжёлых компонентов
  • useTransition для тяжёлых операций
  • Debounce для инпутов
  • Web Workers для вычислений

CLS (< 0.1)

  • width и height для всех изображений
  • aspect-ratio для responsive
  • font-display: swap с fallback-метриками
  • Резервирование места под динамический контент
  • min-height для блоков с загрузкой

Итоги

Приведённые в статье приёмы дают заметный результат — на проекте, с которого писалась эта подборка, метрики изменились так:

МетрикаДоПосле
LCP4,2 с1,8 с
INP350 мс120 мс
CLS0,250,02
⚠️ Важно

А вот от соблазна привязать к этим цифрам конкретный рост конверсии стоит удержаться. Такие связи регулярно встречаются в статьях по производительности, но проверить их на отдельно взятом проекте невозможно: параллельно меняются сезон, реклама, ассортимент и десяток других вещей. Корректное утверждение звучит скромнее — медленный сайт теряет часть пользователей на этапе загрузки, и устранение этих потерь работает в плюс. Насколько именно — измеряется на вашем проекте A/B-тестом, а не берётся из чужой статьи.

Что я бы забрал из этой темы в первую очередь.

Начинайте с измерения, а не с оптимизации. Три четверти времени в таких задачах экономит правильный ответ на вопрос «что именно тормозит». Оптимизация наугад даёт красивые коммиты и нулевой эффект.

LCP чаще всего упирается в картинку и в шрифты, а не в размер JavaScript. Приоритетная загрузка главного изображения и корректная стратегия загрузки шрифтов дают больше, чем недели работы над бандлом.

CLS исправляется дешевле всех трёх метрик — как правило, это явные размеры у изображений и зарезервированное место под баннеры. Начинайте с него, если нужен быстрый результат.

INP — самая сложная, потому что зависит от объёма выполняемого JavaScript, а его сокращение почти всегда означает архитектурные изменения. Здесь и окупаются серверные компоненты.

Следите за метриками постоянно. Один хороший спринт по производительности отыгрывается назад за полгода добавления сторонних скриптов. Регулярный отчёт полезнее разовой оптимизации.

Частые вопросы

Какие пороги у Core Web Vitals?

LCP, время до отрисовки главного элемента, — хорошо меньше 2,5 секунды, плохо больше 4. INP, задержка при взаимодействии, — хорошо меньше 200 миллисекунд, плохо больше 500. CLS, визуальная стабильность, — хорошо меньше 0,1, плохо больше 0,25. Все три влияют на ранжирование в Google.

Почему Lighthouse показывает 100 баллов, а Search Console красную зону?

Потому что это разные данные. Lighthouse и вкладка Performance показывают лабораторные измерения: один прогон, ваш канал, ваше устройство. Google ранжирует по полевым данным CrUX — это реальные посетители с их телефонами и мобильным интернетом. Расхождение бывает драматическим и является обычной ситуацией. Ориентируйтесь на отчёт в Search Console и собственный сбор метрик с реальных пользователей, а Lighthouse используйте для поиска причин, а не для оценки результата.

Как улучшить LCP в Next.js?

LCP чаще всего упирается в картинку и шрифты, а не в размер JavaScript. Ставьте атрибут priority на LCP-изображение в компоненте next/image, настройте форматы avif и webp в next.config, подключайте шрифты через next/font с display swap и предзагрузкой, минимизируйте блокирующий CSS в критическом пути. Приоритетная загрузка главного изображения и корректная стратегия загрузки шрифтов дают больше, чем недели работы над бандлом.

Как уменьшить CLS?

Это самая дешёвая из трёх метрик. Указывайте width и height у всех изображений или используйте контейнер с aspect-ratio и заливкой через fill. Резервируйте место под динамический контент скелетоном той же высоты. Для шрифтов включайте adjustFontFallback, чтобы метрики запасного шрифта совпадали с основным. Под рекламу и iframe задавайте min-height или фиксированное соотношение сторон. Начинайте с CLS, если нужен быстрый результат.

Почему INP оптимизировать сложнее всего?

Потому что она зависит от объёма выполняемого на клиенте JavaScript, а его сокращение почти всегда означает архитектурные изменения. Именно здесь окупаются серверные компоненты: данные, загруженные на сервере, не требуют клиентского fetch и связанного с ним кода. Дополнительно помогают ленивая загрузка тяжёлых компонентов через dynamic(), useTransition для тяжёлых операций и Web Workers для вычислений.

Насколько улучшение Core Web Vitals повышает конверсию?

Конкретную цифру из чужой статьи брать не стоит. Такие связи регулярно встречаются в материалах по производительности, но проверить их на отдельно взятом проекте невозможно: параллельно меняются сезон, реклама, ассортимент и десяток других вещей. Корректное утверждение скромнее — медленный сайт теряет часть пользователей на этапе загрузки, и устранение этих потерь работает в плюс, а насколько именно, измеряется на вашем проекте A/B-тестом.