Цей сайт (той, що ти читаєш) набирає 98 балів мобільного Lighthouse у продакшні, на трьох локалях, із реальним блогом, реальним роутингом і реальною контактною формою. Я не відвантажив статичний HTML-клон заради цього. Ось що реально зрушило число, що не зрушило, і що б я пропустив наступного разу.
Перед чим-небудь: бал Lighthouse — це проксі, а не ціль. Ціль — щоб сайт вантажився швидко для людини на 4G-зʼєднанні в лобі готелю. Якщо оптимізація заради балу робить сайт повільнішим для неї — я ігнорую бал. Згадую, бо більшість гайдів «як добити до 100» оптимізують заради скриншота, а не заради користувача.
Коротко — що зрушило число
За порядком впливу на моїх власних аудитах:
- Самохостинг шрифтів, підрізка до гліфів, які реально використовую
- Заміна кожного декоративного PNG/JPG на inline SVG
- Вимірювання якого клієнтського JS відвантажується і вбивство половини
- Оплата i18n-податку рівно один раз, на етапі збірки
- Tailwind 4 + tree-shaken CSS, без глобального reset-кухонного сміття
Це стек-ранкінг. Останні два часто пропускають у гайдах, і вони варті набагато більше, ніж більшість порад щодо оптимізації зображень.
1. Шрифти — найбільший безкоштовний виграш
Google Fonts через <link> — єдина найчастіша перф-помилка, яку я бачу на сучасних сайтах. Навіть із font-display: swap ланцюжок запитів такий:
- HTML завантажується
- Браузер парсить, знаходить
<link>на fonts.googleapis.com - DNS, TLS, фетч CSS
- Парс, знаходження реальних URL
.woff2(інший домен) - DNS, TLS, фетч шрифтів
- Рендер
Пʼять round-tripʼів до відмальовки тексту. На повільному 4G це 800ms+ порожнього екрана до LCP.
Що роблю я: next/font/local, файли .woff2 у /public/fonts/, підрізані до Latin + Latin Extended + Cyrillic. font-display: swap. Один запит, той самий origin, агресивно кешовано.
Код:
// app/fonts.ts
import localFont from 'next/font/local'
export const inter = localFont({
src: [
{
path: './fonts/Inter-Variable.woff2',
weight: '100 900',
style: 'normal',
},
],
variable: '--font-inter',
display: 'swap',
preload: true,
})
Підрізати .woff2 за допомогою pyftsubset або glyphhanger, щоб викинути CJK, арабські та математичні гліфи, які ніколи не використовуєш. Варіативний шрифт, що був 480KB, став 86KB після підрізки до трьох скриптів, які мені реально потрібні.
Дельта балів: ~+8 на мобільному.
2. SVG для кожного декоративного зображення
Кожна іконка, кожна ілюстрація, кожен акцент на цьому сайті — inline SVG або SVG із /public. Нуль декоративних PNG/JPG. Фото подаються через next/image з AVIF + responsive srcset; просто у мене мало фотографій.
Чому це bigger deal, ніж визнають гайди зі стиснення зображень: SVG — частина HTML-потоку. Вони малюються разом із рештою сторінки. Растрові зображення — це другий запит, друге декодування, і вони блокують LCP, поки не приземляться.
Ціна: вечір у Figma на експорт чистих SVG і ще година на оптимізацію з SVGO. Варто кожної хвилини.
Дельта балів: ~+5 на мобільному, але більший виграш — у відчутній відгукуваності: сторінка візуально завершується за один paint замість двох.
3. Чесний аудит клієнтського JS
Штука, яка смиряє: відкрий DevTools Coverage tab, завантаж свій сайт, поскрольй трохи і подивися, скільки JS ти відвантажив vs скільки реально виконалося.
Мій перший аудит на цьому сайті: 180KB JS відвантажено клієнту, ~40KB реально виконано на first paint. 140KB невикористаних байтів висять у network tab просто так.
Звідки це бралося:
- Імпорт framer-motion, який рендерив три анімації, але імпортував усю бібліотеку
- Syntax highlighter, завантажений eagerly для блоків коду — жодного з яких не було видно на головній
- Бібліотека іконок, імпортована на рівні модуля, коли я використовував чотири іконки (та сама проблема, що з шрифтами)
Патерн фіксу для кожного:
// framer-motion — використовувати lazy-loaded вхід
import { LazyMotion, domAnimation, m } from 'framer-motion'
// syntax highlighter — вантажити тільки коли блок коду видний
const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter'), {
ssr: false,
})
// іконки — імпортувати чотири, які використовую, а не бочку
import ShieldIcon from '@/components/icons/ShieldIcon' // inline SVG, 200 байт
Знизив до ~55KB відвантажених після аудиту, ~40KB виконаних. Залишкові 15KB — роутинг + гідратація, неминучі без відмови від Next.
Дельта балів: ~+6, але важливіше — ~400ms зниження Time to Interactive.
4. i18n-податок, сплачений один раз на етапі збірки
Цей сайт рендериться англійською, російською та українською. Наївний підхід — відвантажувати всі три словники перекладів на кожну сторінку і обирати один у рантаймі. Це ~90KB JSON заради фічі, що обслуговує одну мову за раз.
next-intl (бібліотека, яку я використовую) підтримує статичне завантаження по локалі на етапі збірки з App Router. Кожна локаль компілює свій власний статичний HTML тільки зі своїми повідомленнями. JSON на локаль у клієнтському бандлі: ~8KB, не 90KB.
// i18n/request.ts
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}))
Трюк у шаблонному літералі. Якщо написати await import('../messages/en.json') статично — бандлер відвантажує тільки цей. Якщо з динамічною змінною — Next інлайнить по-локально на етапі збірки, бо роутинг — по-локальний. Одна локаль, один файл повідомлень, нема перемикання на клієнті.
Дельта балів: не величезне число на Lighthouse (може +2 через зменшення бандлу), але це різниця між сайтом, що відчувається швидким, і сайтом, що відчувається роздутим для користувачів, що відкривають DevTools.
5. Tailwind 4, без глобального reset-сміття
Tailwind 4 з @theme і @utility відвантажує частку CSS Tailwind 3 при правильному використанні. Продакшн-бандл цього сайту — ~9KB CSS (gzipped), і включає невелику кастомну тему, повну бібліотеку компонентів і типографіку для MDX.
Дві конкретні речі:
- Нема глобального reset у стилі
@tailwind base, коли він мені не потрібен. Використовую тісний кастомний reset (~200 байт), що обробляє мої реальні крайові випадки, а не 40-селекторний Normalize-стиль дамп. @utilityдля кастомних утиліт, які використовую 5+ разів. Тримає arbitrary-value fallback поза моїм JSX.
Дельта балів: маленька на Lighthouse напряму, але кожен KB CSS — це KB, що затримує first paint на повільних зʼєднаннях.
Що не допомогло
Дві речі, на які я витратив час і які зрушили бал менше ніж на пункт:
Edge-рендеринг усього. Спробував перемістити серверні компоненти на edge runtime на Vercel. Реальне покращення латенсі: 30ms у середньому, рідко потрапляючи на критичний шлях. І втратив доступ до деяких Node-only API, які не хотів рефакторити. Відкотив.
Ручний тюнінг critical CSS extraction. Спробував ручний critical-CSS екстрактор, який інлайнив above-the-fold стилі в <head> і відкладав решту. Технічно працювало — але з Tailwind 4, що вже виробляє 9KB CSS, critical fold по суті був усім файлом. Додав складність заради 0.2 очка покращення.
Якщо CSS 80KB+ на сторінці, critical extraction ймовірно варте того. Нижче 15KB — пропускай.
Що б я зробив інакше
Якщо б стартував цей сайт із нуля завтра, одна річ, яку б зробив раніше: вимірювати з першого дня. Налаштувати Lighthouse CI перевірку в GitHub Actions, ронити збірку нижче порогу, дивитися трейс на кожному PR. Пізні виграші на цьому сайті прийшли після того, як у мене зʼявилася інфраструктура, щоб бачити регресії — яку я налаштував на два місяці пізніше, ніж варто було.
Решта оптимізацій — маленькі виграші, акуратно складені. Нема одного трюку. Якщо ти нижче 90 — почни зі шрифтів. Якщо застряг на 92–94 — аудить свій JS. Якщо на 95+ і женешся за останніми трьома очками — віддача на зусилля падає жорстко.
Якщо хочеш перф-аудит свого Next.js або React Native застосунку — я роблю разові аудити (€500, оборот 3 дні, письмовий звіт). Вкажи в контактній формі, і я надішлю дату.