r3tam blog

Оптимизация производительности Next.js: практическое руководство

Оптимизация производительности Next.js: практическое руководство

Производительность веб-приложений — не просто «хорошо бы иметь». Это метрика, которая влияет на конверсию, SEO и пользовательский опыт. Google использует Core Web Vitals как фактор ранжирования, а пользователи ожидают, что страница загрузится меньше чем за две секунды. Next.js даёт мощные инструменты для достижения высокой производительности, но они работают только при правильном применении.

В этом руководстве мы разберём практические техники оптимизации Next.js-приложений в 2026 году: от Server Components и работы с изображениями до стратегий кэширования и анализа бандла. Каждая техника подкреплена реальными примерами и измеримыми результатами.

Server Components: основа производительности

React Server Components (RSC) — самое значительное улучшение производительности в современном Next.js. Серверные компоненты рендерятся на сервере и отправляют в браузер готовый HTML без единой строчки JavaScript. Это означает нулевой вклад в размер клиентского бандла.

На практике большинство команд совершают одну и ту же ошибку: слишком рано добавляют директиву 'use client'. В кодовых базах, где 80% компонентов — клиентские, весь смысл RSC теряется. Каждая директива 'use client' создаёт границу гидратации. Каждая такая граница — это JavaScript, который браузер должен загрузить, распарсить и выполнить.

Правило простое: все компоненты по умолчанию серверные. Добавляйте 'use client' только когда компоненту действительно нужны браузерные API, обработчики событий, useState или useEffect. Клиентские компоненты должны быть «островками» интерактивности, встроенными в серверную обёртку.

// ✅ Правильно: серверный компонент для данных
async function ProductList() {
  const products = await db.product.findMany();
  return products.map(p => <ProductCard key={p.id} product={p} />);
}

// 'use client' только когда нужна интерактивность
// ✅ Маленький клиентский компонент на границе
function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => { setAdded(true); }}>
      {added ? 'Добавлено' : 'В корзину'}
    </button>
  );
}

При аудите реального проекта с дашбордами, графиками и таблицами перенос компонентов из Client в Server Components сократил начальный JS-бандл с 400 KB до 150 KB. Логика приложения не изменилась — изменилось только то, где она выполняется.

Оптимизация изображений с next/image

Изображения составляют более 50% передаваемых байтов на большинстве страниц. Это первая и самая эффективная точка оптимизации. Компонент next/image в Next.js 15 и 16 автоматически конвертирует изображения в AVIF и WebP, генерирует responsive srcset и лениво загружает изображения ниже сгиба.

Ключевые правила работы с next/image:

  • Всегда указывайте width и height (или fill с позиционированным контейнером). Без них браузер не резервирует пространство, что приводит к Cumulative Layout Shift (CLS).
  • Используйте priority для LCP-изображения. Это добавляет <link rel="preload"> в <head> и отключает ленивую загрузку для главного изображения выше сгиба.
  • Не ставьте priority больше чем на 1–2 изображения. Иначе предзагрузка теряет смысл.
  • Атрибут sizes критически важен. Он предотвращает загрузку изображения шириной 1920 px для блока шириной 400 px. Правильный sizes может сократить вес изображений на 40–60%.
import Image from 'next/image';

// LCP-изображение с priority и sizes
<Image
  src="/hero.webp"
  alt="Hero"
  width={1200}
  height={600}
  priority
  sizes="(max-width: 768px) 100vw, 1200px"
/>

В Next.js 16 next/image использует AVIF по умолчанию и увеличил TTL кэша изображений до четырёх часов. Настройка minimumCacheTTL в конфиге позволяет управлять этим поведением:

// next.config.ts
const config: NextConfig = {
  images: {
    minimumCacheTTL: 86400, // кэш на сутки
  },
};

Оптимизация шрифтов с next/font

Шрифты — вторая по значимости причина проблем с LCP и CLS. Неоптимизированные шрифты вызывают FOIT (Flash of Invisible Text) или FOUT (Flash of Unstyled Text), а их загрузка блокирует рендеринг.

next/font решает эти проблемы комплексно: хостит шрифты на вашем домене (нет кросс-доменных DNS-запросов), генерирует оптимальный font-display: swap и использует size-adjust, чтобы запасной шрифт визуально совпадал с основным — это устраняет layout shift.

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

const inter = Inter({
  subsets: ['latin', 'cyrillic'],
  display: 'swap',
});

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

Если вы используете Google Fonts через CSS-тег <link> в _document, вы теряете все преимущества next/font. Google Fonts с next/font загружаются со статического ресурса вашего домена, а не с googleapis.com, что устраняет лишний DNS-запрос и TLS-рукопожатие.

Кэширование: стратегии для продакшена

Система кэширования в Next.js 15 и 16 претерпела значительные изменения. Главное: ничего не кэшируется по умолчанию — вы явно указываете, что и как кэшировать. Это предсказуемо и управляемо.

Data Cache

Запросы через fetch с опцией next: { revalidate } кэшируются на уровне данных. Для сторонних клиентов (Prisma, Drizzle) используйте unstable_cache или use cache в Next.js 16:

// Next.js 16 — use cache
export default async function Page() {
  const data = await getCachedData();
  return <div>{data}</div>;
}

async function getCachedData() {
  'use cache';
  return await db.product.findMany();
}

Full Route Cache

Статические маршруты рендерятся на этапе сборки и кэшируются на CDN. Для маршрутов, которые не требуют свежих данных при каждом запросе, установите revalidate:

export const revalidate = 3600; // ревалидация раз в час

Это одна из самых простых оптимизаций TTFB. Страница генерируется один раз и отдаётся из кэша в течение часа, а TTFB падает с 500–800 ms до 50–150 ms.

ISR и on-demand ревалидация

Incremental Static Regeneration позволяет сочетать статику с динамикой. При запросе пользователь получает кэшированную версию, а Next.js в фоне перегенерирует страницу, если данные устарели:

// Server Action для инвалидации
'use server';
export async function updateProduct(id: string, data: FormData) {
  await db.product.update({ where: { id }, data });
  revalidatePath(`/products/${id}`);
  // или по тегу:
  revalidateTag('products');
}

Стриминг и параллельная загрузка данных

Самая распространённая ошибка производительности — последовательные await, которые не зависят друг от друга. Это создаёт водопады (waterfalls), где каждый следующий запрос ждёт завершения предыдущего.

Параллельная загрузка:

// ❌ Последовательно — 400 ms total
const user = await getUser(id);
const posts = await getPosts(id); // ждёт getUser

// ✅ Параллельно — 200 ms total
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
]);

Для рендеринга используйте Suspense-границы. Они позволяют отправить shell страницы сразу, а медленные секции — стримить по мере готовности:

export default function DashboardPage() {
  return (
    <div>
      <h1>Дашборд</h1> {/* рендерится сразу */}
      <Suspense fallback={<Skeleton />}>
        <SlowCharts /> {/* стримится когда готово */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <DataTable /> {/* стримится независимо */}
      </Suspense>
    </div>
  );
}

В Next.js 16 Partial Prerendering (PPR) стабилизирован. PPR позволяет рендерить части страницы статически (статический shell), а динамические секции — стримить. Это даёт скорость статического сайта с гибкостью динамического.

Анализ и оптимизация бандла

Даже с Server Components часть JavaScript всё равно уходит на клиент. Контролировать размер бандла нужно системно.

Bundle Analyzer

Плагин @next/bundle-analyzer показывает, какие зависимости сколько весят:

ANALYZE=true npm run build

Это визуальный отчёт: вы сразу видите, что lodash весит 70 KB, а date-fns — 130 KB, при том что используется пара функций.

Dynamic Imports

Тяжёлые библиотеки — редакторы кода, графики, карты — загружайте лениво через next/dynamic. Пользователь, который не открывает редактор, не должен платить за его загрузку:

import dynamic from 'next/dynamic';

const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  { loading: () => <p>Загрузка редактора...</p>, ssr: false }
);

optimizePackageImports

Настройка optimizePackageImports в next.config.ts разительно сокращает бандл для библиотек с древовидной структурой экспорта:

const config: NextConfig = {
  experimental: {
    optimizePackageImports: ['@iconify/react', 'lodash', 'date-fns'],
  },
};

Для @iconify/react (сотни иконок) это даёт экономию в десятки килобайт.

React Compiler

Начиная с React 19, компилятор автоматически мемоизирует компоненты, устраняя необходимость в ручных useMemo и useCallback. В Next.js 16 он включается через reactCompiler: true в конфиге.

Важно: включение React Compiler на крупном проекте с существующими оптимизациями может вызвать неожиданные сайд-эффекты. Данные могут перестать обновляться, когда должны, или наоборот — рендериться слишком часто. Начинайте с малых проектов или тщательно тестируйте.

В одном из реальных кейсов отключение React Compiler дало +26 баллов PageSpeed, потому что автоматическая мемоизация конфликтовала с ручной оптимизацией, создавая избыточные рендеры. Вывод: каждую экспериментальную фичу нужно измерять на своём проекте.

Core Web Vitals: практический чек-лист

Google измеряет производительность по трём метрикам. Для статуса «хорошо» 75% реальных сессий должны проходить все три порога:

| Метрика | Хорошо | Требует улучшения | Плохо |

|---------|--------|-------------------|-------|

| LCP | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s |

| INP | ≤ 200 ms | 200–500 ms | > 500 ms |

| CLS | ≤ 0.1 | 0.1–0.25 | > 0.25 |

LCP (Largest Contentful Paint)

Главная причина плохого LCP — неоптимизированное изображение выше сгиба. Решения:

  • next/image с priority для LCP-элемента
  • Правильный sizes — чтобы мобильные устройства не загружали десктопные изображения
  • Хостинг шрифтов через next/font — Google Fonts добавляет 200–400 ms к LCP

INP (Interaction to Next Paint)

INP отражает отзывчивость интерфейса. Основные причины плохого INP:

  • Слишком много Client Components — каждый добавляет работу на главном потоке
  • Тяжёлые библиотеки, загруженные синхронно
  • Долгие задачи (long tasks) на клиенте

Решение: Server Components по умолчанию, динамический импорт тяжёлых зависимостей, Suspense-границы для стриминга.

CLS (Cumulative Layout Shift)

Layout shift возникает, когда браузер уже отрисовал элемент, а потом загрузился контент, который сдвигает его. Основные причины:

  • Изображения без width/height — браузер не зарезервировал место
  • Шрифты без font-display: swap и size-adjust — при загрузке шрифта текст меняет размер
  • Рекламные баннеры и инжектированный UI

Решение: next/image с размерами, next/font с display: swap, резервирование места под динамические элементы.

Заключение

Оптимизация Next.js — не набор магических настроек, а системный процесс. Каждая техника даёт измеримый результат, и при правильном подходе 90+ баллов PageSpeed достижимы для большинства приложений.

Начните с малого: аудит бандла, Server Components, next/image для всех изображений, next/font для шрифтов. Эти три шага обычно поднимают производительность с 60 до 85 баллов. Дальше — стриминг, кэширование, PPR и тонкая настройка INP.

Главное правило: измеряйте до и после. Без измеримых метрик оптимизация превращается в угадайку. Используйте next build с bundle-analyzer, PageSpeed Insights, Search Console и мониторинг Core Web Vitals в продакшене. Только данные показывают, что действительно работает для вашего проекта.