Background Logo Silhouette

Next.js Sitenizin PageSpeed Skorunu Mobil'de 35'ten 80'e Nasıl Çıkarırsınız? (Gerçek Bir Vaka Analizi)

AD
Ali DARIAuthor • Developer
August 15, 202625 min read
Next.js Sitenizin PageSpeed Skorunu Mobil'de 35'ten 80'e Nasıl Çıkarırsınız? (Gerçek Bir Vaka Analizi)
Yazar Notu

Bu yazı, büyük ölçekli bir Next.js uygulamasında yürüttüğüm gerçek bir performans optimizasyon sürecini anlatıyor. Commit geçmişi, Lighthouse JSON raporları ve HAR analizleri üzerinden delillendirdiğim bu süreci mümkün olduğunca somut tutmaya çalıştım. Proje detaylarından bahsetmiyorum — teknik yaklaşıma odaklanıyorum.

Optimizasyon Özeti (Mobil Temel Metrikler)

Mobil PageSpeed Skoru+45 Puan
35
80/ 100

🔴 Kötü (Poor) seviyeden 🟢 Good sınırına geçiş

TBT (Total Blocking Time)-%79
970
200ms

Main thread bloklama süresinde devasa düşüş

LCP (Largest Contentful Paint)-2.4s
7.4
5.0s

İlk büyük görsel ve metin çizim süresi

CLS (Cumulative Layout Shift)-%87
0.098
0.013

Layout shift sıfıra yaklaştırıldı

İçindekiler

  1. Başlangıç Noktası: Sayılar Neredeydi?
  2. Ölçüm Araçları: Ne Kullandım, Neden?
  3. Faz 1 — Düşmanı Tanı: Bundle Analizi ve HAR Takibi
  4. Faz 2 — 3. Parti Scriptlerin Taşınması: GTM ve Yandex Metrica → Partytown
  5. Faz 3 — Görsel Optimizasyon: LCP'yi Kırmak
  6. Faz 4 — Font Stratejisi: FOIT, FOUT ve CLS
  7. Faz 5 — CSS Disiplini: Critical CSS ve Chunk Stratejisi
  8. Faz 6 — JavaScript Bundle'ı İnceltiriz: Dynamic Import ve Lazy Loading
  9. Sonuç: Nereden Nereye?
  10. Öğrendiklerim ve Tavsiyeler

1. Başlangıç Noktası

Projeye başladığımda ilk Lighthouse taramasının sonuçları şöyleydi:

Mobil — İlk Durum (Baseline)

MetrikDeğerDurum
Performance Score35–46 / 100🔴 Kötü
FCP (First Contentful Paint)2.8 s🟡
LCP (Largest Contentful Paint)7.4 s🔴
TBT (Total Blocking Time)970 ms🔴
CLS (Cumulative Layout Shift)0.098🟢
TTI (Time to Interactive)9.1 s🔴
Speed Index3.7 s🟡

Bu "Needs Improvement" bölgesinin de altındaydı. Mobilden site açan kullanıcılar için ilk anlamlı içeriğin 7+ saniyede gelmesi gerçek bir UX krizi anlamına geliyordu.

Peki neyin sebebiyet verdiğini nasıl bulduk?

2. Ölçüm Araçları

Tek bir araçla çalışmadım. Her araç farklı bir şey gösteriyor:

Kullandığım Araçlar

AraçNe için?Hangi ortam?
Lighthouse CLICore Web Vitals, fırsat analiziLocal + CI
Chrome DevTools — Performance tabMain thread flame chart, long tasksLocal dev
Chrome DevTools — Network tabRequest waterfall, transfer sizeLocal dev
HAR (HTTP Archive) dosyalarıAğ akışının tam kaydı, analiz scriptiProduction
PageSpeed InsightsLab + Field data karşılaştırmasıProduction URL
@next/bundle-analyzerJS chunk ağırlıkları, hangi paket neredeBuild time
Web Vitals (Chrome Extension)Gerçek kullanıcı verisi, CrUXTarayıcı
Önemli Fark: Lab Data vs. Field Data

Lighthouse simüle edilmiş bir ortamda çalışır (3G throttling, 4x CPU slowdown). PageSpeed Insights'ın Field Data bölümü ise Chrome'un gerçek kullanıcı verisini (CrUX) gösterir. İkisi arasında bazen 20–30 puan fark olabilir — ikisini birden bakın.

Benim Workflow'um

# Lighthouse CLI ile local analiz lighthouse "http://localhost:3000/en/property-turkey" \ --preset=perf \ --form-factor=mobile \ --throttling-method=simulate \ --only-categories=performance \ --output=json \ --output-path="analyze/search-mobile-baseline.json" # Sonra Python scripti ile rapor üret python3 scripts/lighthouse-analyze.py analyze/search-mobile-baseline.json

Her optimizasyon adımından sonra yeni bir JSON kaydettim ve öncekiyle karşılaştırdım. Bu sayede her commit'in etkisini somut olarak görebildim.

3. Faz 1 — Düşmanı Tanı: Bundle Analizi ve HAR Takibi

İlk iş: neyin ağır olduğunu anlamak.

@next/bundle-analyzer ile JS Haritası

next.config.js'e şunu ekledim:

const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', });
ANALYZE=true npm run build

Ortaya çıkan tablo şöyleydi — hangi paket nerede, kaç KB transfer ediliyor:

SuçluBoyutSorun
gtm.js (Google Tag Manager)155 KB%56'sı kullanılmıyor, main thread'i bloklayan sync script
Yandex Metrica (tag.js)~254 KBMain thread'de 3–4s meşgul ediyor
ReactPlayer67 KBHer sayfada yükleniyor, sadece detail sayfasında kullanılıyor
Unused CSS chunks76–72 KB%70–100 kullanılmıyor, render blocking
Unused JS chunks146–318 KBCritical path'te, hiç tetiklenmiyor

HAR Analizi ile Network Darboğazı

Chrome DevTools'dan export ettiğim HAR dosyasını kendi yazdığım bir analiz scriptiyle işledim. İlk yükleme waterfall'unda görülen sorun: render-blocking requestler zincirleme sıralanıyordu ve tarayıcı ilk frame'i çizmek için saniyeler bekliyordu.

Tespit Edilen En Kritik 3 Sorun
  1. GTM scripti <head>'de senkron yükleniyor → her şeyi blokluyor
  2. Google Fonts <link rel="stylesheet"> render-blocking
  3. Görsel sizes attribute'u yanlış → tarayıcı gereksiz büyük resim indiriyor

4. Faz 2 — 3. Parti Scriptler → Partytown

Bu, tek bir commit'le en büyük TBT kazanımını sağlayan adım oldu.

Problem

GTM ve Yandex Metrica, main thread'de çalışıyordu. Lighthouse'un "JS Bootup" bölümünde şunlar görünüyordu:

gtm.js → 155 KB, %56 unused Yandex tag.js → 254 KB, main thread'i ~3s bloklıyor

Bu scriptlerin her ikisi de Total Blocking Time'ı doğrudan artırıyordu. TBT yüksekse TTI de geç gelir, skor düşer.

Çözüm: Partytown

Partytown, 3. parti scriptleri ana thread'den alıp bir Web Worker'a taşır. Böylece GTM, Yandex Metrica gibi ağır scriptler main thread'i meşgul etmez.

Head.jsx'e eklediğim Partytown config:

<script dangerouslySetInnerHTML={{ __html: `partytown = { forward: ['dataLayer.push', 'ym'], debug: false, lib: '/~partytown/' };`, }} />

YandexMetrica bileşeni — önceden next/script strategy='lazyOnload' ile main thread'deydi, Partytown'a taşındı:

// Eski: main thread'de çalışıyordu <Script strategy="lazyOnload" src="https://mc.yandex.ru/metrika/tag.js" /> // Yeni: Web Worker'da çalışıyor — main thread'e 0 etki <script type="text/partytown" src="https://mc.yandex.ru/metrika/tag.js" />

Partytown'un Sınırı: Browser Uyumluluğu

Burada önemli bir sorunla karşılaştım: Partytown tüm browserlarda çalışmıyor.

Web Worker API'sini desteklemeyen veya kısıtlayan ortamlarda (bazı eski mobile browser'lar, belirli privacy extension'ları, Lighthouse'un simülasyon ortamı) Partytown sessizce başarısız olabilir. Tracking event'leri kaybolur, GTM hiç yüklenmez.

Bunu çözmek için şu pattern'i uyguladım: Partytown snippet'i yüklemeden önce Web Worker desteğini kontrol et; desteklemiyorsa scriptleri doğrudan main thread'e fallback et:

// BodyScripts.jsx — GTM debug bypass + Worker fallback (function() { // GTM debug mode tespit: partytown yerine direkt main thread var K = '_gtm_debug'; var U = /[?&](gtm_debug|gtm_preview)=/.test(location.search); var D = U; if (U) { try { sessionStorage.setItem(K, '1') } catch(e) {} } else { try { D = sessionStorage.getItem(K) === '1' } catch(e) {} } if (D) { // Fallback: type="text/partytown" scriptleri // normal <script> olarak DOM'a ekle function run() { var pts = document.querySelectorAll('script[type="text/partytown"]'); for (var i = 0; i < pts.length; i++) { var s = document.createElement('script'); if (pts[i].src) { s.src = pts[i].src; s.async = true; } else { s.textContent = pts[i].textContent; } document.head.appendChild(s); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', run); } else { run(); } return; // Partytown'u başlatma } // Normal akış: Partytown snippet'i yükle // ... })();

Etkileşim-Tetiklemeli GTM Yükleme (Interaction-Gated Loading)

Bu, projede şu an da aktif olan başka bir optimizasyon katmanı: GTM'i sayfa yüklenir yüklenmez değil, kullanıcı bir şeyler yapana kadar beklet.

Kullanıcı sayfaya girip hemen çıkacaksa GTM'in yüklenmesi gereksiz. Kullanıcı kaydırma yaparsa, tıklarsa veya klavyeye basarsa — o zaman GTM yüklensin. Eğer hiç etkileşim olmadıysa, maksimum 5 saniye sonra yine de yüklenir (bots ve pasif kullanıcılar için güvenlik ağı).

// GTMScript.jsx — Interaction-gated loader (gerçek implementasyon) (function() { var done = false; function initGTM() { if (done) return; done = true; // Event listener'ları temizle ['scroll', 'click', 'touchstart', 'mousemove', 'keydown'].forEach(function(e) { document.removeEventListener(e, initGTM, { capture: true }); }); // GTM'i dinamik olarak DOM'a ekle window.dataLayer = window.dataLayer || []; window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); var f = document.getElementsByTagName('script')[0]; var j = document.createElement('script'); j.async = true; j.src = '/gtm/script.js?id=GTM-XXXXXXX'; // First-party proxy üzerinden f.parentNode.insertBefore(j, f); } // 1. Kullanıcı etkileşiminde hemen yükle ['scroll', 'click', 'touchstart', 'mousemove', 'keydown'].forEach(function(e) { document.addEventListener(e, initGTM, { capture: true, once: true, passive: true }); }); // 2. Etkileşim yoksa 5 saniye sonra yükle (fallback) if (document.readyState === 'complete') { setTimeout(initGTM, 5000); } else { window.addEventListener('load', function() { setTimeout(initGTM, 5000); }, { once: true }); } })();

Bu pattern'in Lighthouse'a etkisi: Lighthouse simülasyonunda kullanıcı etkileşimi olmadığı için GTM hiç yüklenmez — TBT sıfır etki. Gerçek kullanıcılarda ise ilk scroll ile yüklenir.

Önemli dataLayer Sırası

dataLayer init'i (window.dataLayer = window.dataLayer || []) hala sayfanın başında senkron çalışmalı — böylece GTM yüklenmeden önce de event'ler kuyruğa girer ve GTM yüklenince hepsini okur. Sadece gtm.js dosyasının kendisi erteleniyor.

Sonuç (Partytown öncesi → sonrası)

MetrikÖnceSonraFark
Performance Score4554+9
TBT3,666 ms2,070 ms-1,596 ms
TTI27.0 s13.6 s-13.4 s
3. parti tracking CPU~3,900 ms~132 ms-97% azalma
Öğrenim

Partytown + interaction-gated loading kombinasyonu güçlü ama dikkat gerektiriyor. ym() ve dataLayer.push gibi fonksiyon çağrılarını forward array'ine eklemeyi unutursanız tracking bozulur. Her deployment sonrası event'lerin gerçekten geldiğini GA4 / GTM Preview ile doğrulayın.

5. Faz 3 — Görsel Optimizasyon: LCP'yi Kırmak

LCP'nin 7.4 saniye olması kabul edilemezdi. Sorun çok katmanlıydı.

5.1 — sizes Attribute Yanlışlığı

Next.js <Image> bileşeninin sizes prop'unu yanlış vermek, tarayıcının çok büyük bir görsel indirmesine neden olur.

// Eski — kartın 33vw olduğunu söylüyoruz ama mobilde tam genişlik kullanılıyor sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" // Yeni — gerçek render boyutuna göre sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"

Küçük görünüyor ama etkisi büyük: mobilde her kart için indirilen görsel boyutu yaklaşık yarıya indi.

5.2 — LCP Görseline priority Vermek

Sayfanın ilk görünür alanındaki görsel LCP elemanıdır. Next.js'de buna priority prop'u verilmezse loading="lazy" olarak işaretlenir ve tarayıcı onu keşfetmekte geç kalır.

// PropertyCard — arama listesinde ilk 2-3 kart <CustomImage priority={index < 3} // Sadece ilk kartlar priority sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw" />

5.3 — decoding="async" Zorunluluğu

decoding="sync" ana thread'i decode işlemi için bloklar. Tüm görsellere decoding="async" uygulandı:

// CustomImage.jsx decoding="async" // priority olsa bile — decode CPU'ya paralel yapılsın

5.4 — Image Quality ve Format

// next.config.js images: { formats: ['image/webp'], // WebP önce qualities: [60, 75, 85], // Kullanılan quality değerleri minimumCacheTTL: 31556926, // 1 yıl cache }

5.5 — Skeleton Loader ile CLS Azaltma

Sayfa yükleme sırasında içerik "aşağı itmesi" CLS'yi artırıyordu. Gerçek içerik gelene kadar aynı yükseklikte skeleton gösterdik:

const SkeletonCard = () => ( <div className={styles.skeletonCard}> <div className={styles.skeletonImage} /> <div className={styles.skeletonBody}> <div className={styles.skeletonLine} /> <div className={styles.skeletonLine} /> </div> </div> );

LCP Sonuçları

MetrikÖnceSonra
LCP7.4 s5.0 s
CLS0.0980.013
Transfer (görsel başına)~49 KB~25 KB

6. Faz 4 — Font Stratejisi: FOIT, FOUT ve CLS

Fontlar, yanlış yönetildiğinde hem görsel kayma (CLS) hem de render bloklama yaratır.

Problem

Başlangıçta Google Fonts <link rel="stylesheet"> olarak <head>'de yükleniyordu:

<!-- Eski yöntem — RENDER BLOCKING --> <link href="https://fonts.googleapis.com/css2?family=Lato..." rel="stylesheet">

Bu, tarayıcının HTML parse'ı durdurup CSS'i indirip parse etmesi demek. Render-blocking time: ~490–610 ms.

Çözüm: next/font ile Otomatik Optimizasyon

next/font/google modülü, fontu build time'da indirir, kendi CDN'inizden serve eder ve otomatik olarak font-display ekler:

// layout.js import { Lato, Roboto, Vazirmatn } from 'next/font/google' const lato = Lato({ subsets: ['latin'], weight: ['400', '700', '900'], display: 'swap', // FOIT yok — metin hemen görünür variable: '--font-lato', adjustFontFallback: true, // Fallback font metriklerini asıl fonta uydurur preload: false, // Paralel woff2 yarışını önler }) const vazirmatn = Vazirmatn({ subsets: ['arabic', 'latin'], weight: 'variable', display: 'swap', variable: '--font-vazirmatn', adjustFontFallback: true, preload: false, })

RTL Diller İçin Koşullu Font Yükleme

Arapça, Farsça, Urduca gibi RTL dillerde Latin font işe yaramıyor. Öte yandan her kullanıcıya Vazirmatn yüklemek gereksiz:

const LOCALES_VAZIRMATN = ['ar', 'fa', 'pk'] const fontClassName = LOCALES_VAZIRMATN.includes(locale) ? `${vazirmatn.variable} sh-font-vazirmatn` : `${lato.variable} ${roboto.variable}`

Bu sayede Türkçe/İngilizce kullanıcılar Vazirmatn'ı hiç indirmiyor.

adjustFontFallback Neden Önemli?

Bu özellik, sistem fontunu (Arial gibi) asıl fontun metriklerine göre ayarlıyor. Böylece font yüklendiğinde layout shift oluşmuyor — CLS sıfıra yaklaşıyor.

Font Optimizasyon Sonuçları

DurumRender-blocking timeCLS
Google Fonts <link>~490–610 ms0.08+
next/font + display: swap + adjustFontFallback0 ms0.013

7. Faz 5 — CSS Savaşı: En Çok Zaman Harcadığım Alan

Bu bölüm, tüm optimizasyon sürecinin en uzun soluklu ve en sinir bozucu kısmıydı. Yüzeysel görünüyor ama derinlere inince Next.js'in mimarisel bir tasarım kararıyla karşı karşıya kaldım.

7.1 — Asıl Problem: 1.2 MB Monolitik CSS Dosyası

HAR analizi açıldığında ilk karşılaştığım şey şuydu:

CSS | 1,282 KB | 3 istek → 587c40c8b7bad8a4.css : 1,219 KB ← TEK DOSYA, render-blocking → phoneinput.css : 44 KB → Google Fonts : 19 KB

1.2 MB CSS, render-blocking, tek seferde. Sayfa ilk çizilmeden önce tarayıcı bu dosyayı indirip parse etmek zorundaydı.

İçinde ne vardı?

KaynakKatkı
Full Bootstrap (tüm bileşenler)~250+ KB derlenmiş
5,545 unique selectorÇoğu o sayfada kullanılmıyor
459 @media queryDevasa overhead
52 @keyframes animasyonuBüyük çoğunluğu ilk sayfada tetiklenmiyor
Swiper CSSGlobal olarak eklenmiş
Blog sayfası stilleriAna sayfada yok
Arama sayfası stilleriAna sayfada yok
Detail sayfası stilleriAna sayfada yok

theme.scss tam bootstrap'ı import ediyordu:

@import 'bootstrap/scss/bootstrap'; // Tüm Bootstrap — sayfa ihtiyacı ne olursa olsun

7.2 — Next.js'in CSS Önyükleme Davranışı: Tasarımsal Bir Karar

İşte burada gerçek sorunla karşılaştım ve uzun süre araştırdım.

Next.js, aynı layout'u paylaşan tüm sayfaların CSS'ini ilk ziyaret edilen sayfaya yükler.

Neden? Çünkü Next.js client-side navigasyonu hızlandırmak için optimize ediyor. Kullanıcı /en ana sayfasından /en/property-turkey arama sayfasına geçtiğinde, CSS zaten indirilmiş olsun diye arama sayfasının stillerini de ana sayfada önceden yüklüyor.

Bu bir bug değil — bilinçli bir tasarım kararı. Navigasyon hızlanıyor ama ilk sayfa yükü acı çekiyor.

Ana sayfa yükleniyor → ├── ana sayfa CSS'i ✅ gerekli ├── arama sayfası CSS'i ← 🤔 kullanıcı daha oraya gitmedi ├── blog sayfası CSS'i ← 🤔 kullanıcı daha oraya gitmedi └── detail sayfası CSS'i ← 🤔 kullanıcı daha oraya gitmedi

Bunu engellemek için çok zaman harcadım. Sonunda anladım ki bu davranışı Next.js konfigürasyonundan tamamen kapatmak mümkün değil — ama kontrol altına alınabilir.

7.3 — Next.js Versiyonu Yükseltme: Monolitik → Parallel Chunks

Projenin eski bir Next.js sürümünde olduğunu keşfettim. Eski sürüm tüm CSS'i tek bir monolitik dosyada indiriyordu.

Yeni Next.js sürümüne geçince CSS chunk davranışı değişti:

DurumCSS Yükleme Stratejisiİlk Yük
Eski Next.js1–2 MB tek dosya, sıralıÇok yavaş
Yeni Next.jsParalel chunk'lar, bağımsız indirmeDaha hızlı

Tek dosya → paralel chunk'lar geçişi büyük bir kazanım sağladı. Tarayıcı artık 5–6 küçük CSS dosyasını paralel olarak indiriyor, birbirini beklemeksizin.

Ancak sorun tamamen çözülmedi: diğer sayfaların CSS'leri hala yükleniyordu, sadece artık sıralı değil paralel yükleniyordu.

7.4 — cssChunking: 'strict' ile Kontrol

Next.js'in cssChunking seçeneği burada devreye giriyor:

// next.config.js experimental: { // 'loose' (varsayılan): CSS chunk'ları agresif birleştirir, // diğer sayfaların CSS'lerini de mevcut sayfaya ekler // // 'strict': Her route sadece kendi CSS chunk'larını alır, // paylaşılan CSS layout seviyesinde kalır cssChunking: 'strict', }

strict modda ne oluyor?

Ana sayfa yükleniyor → ├── layout.css (paylaşılan — her sayfada gerekli) ✅ ├── home.css (sadece ana sayfaya ait) ✅ └── arama CSS'i, blog CSS'i, detail CSS'i ❌ yüklenmez

Kazanım var ama tam değil: layout seviyesindeki CSS (Bootstrap global stilleri gibi) hala yükleniyor çünkü her sayfada paylaşılıyor.

7.5 — Unused CSS Lighthouse Uyarıları

strict moda geçtikten sonra Lighthouse raporu şu hale geldi:

CSS savings: 43 KB (önceden 76 KB) - 0kv~rqz45q5xk.css: 28/28 KB kullanılmıyor (%97) - 0hjqzqcs40zgy.css: 14/14 KB kullanılmıyor (%100)

Hala "kullanılmıyor" uyarısı vardı ama boyutu %43 azaldı. Buradaki "kullanılmıyor" CSS'in o sayfanın ilk görünümünde tetiklenmediği anlamına geliyor — bu phoneinput.css gibi form stillerinin ana sayfada yüklenmesinden kaynaklanıyordu.

7.6 — Critical Inline CSS: Beyaz Ekran Sorununu Çöz

CSS chunk'lar indirilirken sayfa tamamen beyaz görünüyordu. Çözüm: above-the-fold için gereken minimum CSS'i doğrudan <style> içinde gömmek:

// Head.jsx <style dangerouslySetInnerHTML={{ __html: ` body{margin:0;font-family:var(--font-lato),Lato,Arial,sans-serif; -webkit-font-smoothing:antialiased;background:#fff} .page-wrapper{min-height:100vh;display:flex;flex-direction:column} img{max-width:100%;height:auto} ` }} />

Bu ~200 byte'lık CSS sayesinde tarayıcı CSS chunk'ları beklemeden temel layout'u çiziyor.

Critters & Turbopack Uyarısı

experimental: { optimizeCss: true } (Critters) bunu otomatik yapıyor ama Turbopack modunda çalışmıyor — build kırılıyor. Bu yüzden manuel fallback gerekti.

CSS Sürecinin Özeti

AdımYapılanEtki
Next.js versiyonu yükseltMonolitik CSS → paralel chunk'larİlk yüklemede ciddi kazanım
cssChunking: 'strict'Diğer sayfa CSS'lerinin büyük kısmı engellendiUnused CSS %43 azaldı
Critical inline CSSBeyaz ekran sorunu giderildiFCP görsel olarak iyileşti
Bootstrap seçici importGlobal @import 'bootstrap' kısmen daraltıldıCSS toplam boyutu azaldı

Gerçek öğrenim: Bu alanda "mükemmel" sonuca ulaşmak mümkün değil. Next.js'in prefetch davranışı bir UX kararı — onu tamamen kapatırsanız client navigation yavaşlar. Yapabildiğiniz en iyi şey: strict chunking + versiyon güncellemesi + layout-level CSS'i minimize etmek.

8. Faz 6 — JS Bundle İnceltiriz: Dynamic Import ve Lazy Loading

8.1 — Navbar'ı Ayrı Chunk'a Al

Navbar, React Bootstrap bileşenleri, Iconify ikonları ve mega menü içeren ağır bir bileşendi. Ana bundle'a dahil olunca LCP görseli ile aynı anda parse edilip hydrate ediliyordu — yarışı kaybediyordu.

// RootLayout.jsx — önceden statik import import ClientNavbar from '@/components/layouts/ClientNavbar' // Sonra: ayrı chunk, ssr: true ile SSR kayıp yok, loading placeholder CLS önler const ClientNavbar = dynamic( () => import('@/components/layouts/ClientNavbar'), { ssr: true, loading: () => ( <div className="site-navbar-placeholder" style={{ minHeight: 105 }} aria-hidden aria-busy /> ), } )

8.2 — ReactPlayer: Sadece Detail Sayfasında

ReactPlayer video oynatıcı (~67 KB) arama listesinde de yükleniyordu. Oysa orada hiç video yoktu.

Önceki yaklaşım: Her sayfada import ReactPlayer from 'react-player'

Sonraki yaklaşım: IntersectionObserver ile görünürlük kontrollü lazy mount:

// Video.jsx const ReactPlayer = dynamic(() => import('react-player/lazy'), { ssr: false, loading: () => <div className="video-placeholder" />, }) // Sadece görünür olduğunda yükle const [isVisible, setIsVisible] = useState(false) const ref = useRef(null) useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setIsVisible(true) }, { threshold: 0.1 } ) if (ref.current) observer.observe(ref.current) return () => observer.disconnect() }, [])

8.3 — Bootstrap Bileşenlerini Lazy Yükle

Bootstrap bileşenleri kritik yolda gereksiz yer kaplıyordu:

// Eski: statik import — her sayfada yükleniyor import Spinner from 'react-bootstrap/Spinner' import Button from 'react-bootstrap/Button' // Yeni: lazy — sadece ihtiyaç olduğunda yükleniyor const Spinner = dynamic( () => import('react-bootstrap/Spinner'), { ssr: false, loading: () => <div style={{width:32,height:32}} /> } )

8.4 — requestIdleCallback ile Non-Critical Kodları Ertele

Kullanıcı konumu, pageview tracking gibi non-critical işlemleri tarayıcı boşta kalana kadar erteledik:

useEffect(() => { const run = () => getUserLocation() if (typeof requestIdleCallback === 'function') { const id = requestIdleCallback(run, { timeout: 2500 }) return () => cancelIdleCallback(id) } // Fallback const t = setTimeout(run, 1) return () => clearTimeout(t) }, [])

8.5 — GTM'i Optimize Et: Duplicate Kaldır

Hem GTM hem Yandex Metrica ayrı ayrı yükleniyordu. GTM zaten dataLayer üzerinden Yandex Metrica'yı tetikleyebilir. Duplicate kaldırıldı.

JS Optimizasyon Özeti

OptimizasyonTasarrufEtki
ReactPlayer lazy load~67 KB (arama sayfası)TBT azalma
Bootstrap lazy load~32 KBParse time azalma
Navbar ayrı chunkAna bundle daha hızlı parseLCP iyileşme
GTM duplicate kaldır~155 KB tekrarlı yükleme önlendiTBT azalma
requestIdleCallbackNon-critical işlemler ertelendiTBT azalma

9. Sonuç: Nereden Nereye?

Tüm fazların tamamlanmasından sonraki sonuçlar:

Mobil — Arama Sayfası

MetrikBaşlangıçSonuçDeğişim
Performance Score35–4670–80+34–44 puan
FCP2.8 s2.5 s-300 ms
LCP7.4 s5.0 s-2.4 s
TBT970 ms200 ms-770 ms (-79%)
CLS0.0980.013-87%
TTI9.1 s7.0 s-2.1 s
Speed Index3.7 s2.5 s-1.2 s

Ana Sayfa (Production)

MetrikDeğerDurum
Performance Score78🟡 Good sınırında
FCP1.8 s🟡
LCP3.0 s🟡
TBT590 ms🟡
CLS0.034🟢

Desktop Karşılaştırması

SayfaÖnceSonra
Arama (desktop)31 / 100 🔴53+ / 100 🟡
Ana sayfa (mobil)~35 / 100 🔴78 / 100 🟡

Google'ın Renk Skalası

0–49 → 🔴 Poor (Needs Improvement'ın da altı) 50–89 → 🟡 Needs Improvement 90–100 → 🟢 Good

"Needs Improvement" → "Good" sınırına yaklaştık ve önemli olan bazı sayfalar 80+ bandına girdi.

10. Öğrendiklerim

İşe Yarayanlar

  1. Partytown büyük kazanım ama dikkat ister: GTM ve Yandex Metrica'yı web worker'a taşımak TBT'yi dramatik şekilde düşürdü. Ancak forward array'i eksik olursa tracking bozulur. Her deployment sonrası event'lerin geldiğini doğrulayın.
  2. next/font + adjustFontFallback CLS'yi neredeyse sıfırladı: Google Fonts <link>next/font geçişi hem render-blocking'i kaldırdı hem de layout shift'i minimize etti. adjustFontFallback: true özelliği göz ardı edilmemeli.
  3. sizes prop'u küçük görünür, büyük etki yapar: 100vw ile 50vw arasındaki fark, mobilde indirilen görsel boyutunu ikiye katlar. Her Image bileşeninde gerçek render boyutuna göre sizes vermek şart.
  4. Dynamic import + loading placeholder ikili: loading: () => <div style={{minHeight: X}} /> ile placeholder vermek CLS'yi önler. Boş loading: () => null kullanırsanız layout kayması olabilir.
  5. requestIdleCallback ile non-critical işlemleri ertele: Konum alma, analytics push gibi işlemler LCP'den sonra yapılabilir. Main thread'i gereksiz meşgul etmeyin.

Dikkat Edilmesi Gerekenler

  1. Critters (optimizeCss) Turbopack ile çalışmıyor: experimental: { optimizeCss: true } özelliği harika bir fikir ama Turbopack modunda çalışmıyor. Build süreci kırılabilir. Fallback olarak manuel critical CSS gömmek gerekti.
  2. Partytown her scriptle uyumlu değil: Bazı 3. parti scriptler web worker ortamında çalışmıyor. DOM erişimi gerektiren scriptleri Partytown'a taşımayın.
  3. font-display: optional CLS'yi önler ama FOIT yaratır: İlk etapta display: 'optional' kullandım. Font geç gelince hiç gösterilmiyordu — bu da kötü UX. display: 'swap' + adjustFontFallback kombinasyonu daha iyi.
  4. Tek seferlik değil, sürekli bir süreç: Her yeni özellik veya 3. parti entegrasyon performansı etkileyebilir. CI'ya Lighthouse entegre edin, bütçe belirleyin.

Bonus: Hızlı Kontrol Listesi

Kendi projenize uygulayabileceğiniz öncelikli adımlar:

Optimizasyon Kontrol Listesi
  • @next/bundle-analyzer ile bundle haritasını çıkar

  • Lighthouse CLI ile mobil baseline oluştur ve sakla

  • GTM / analytics scriptlerini Partytown'a taşı

  • next/font kullan, Google Fonts <link> stylesheet'i kaldır

  • Her <Image> bileşeninde sizes prop'unu gerçek render boyutuna göre ver

  • LCP elemanına priority ekle

  • Tüm görsellere decoding="async" ekle

  • Ağır bileşenler (navbar, modal, video) için dynamic() import kullan

  • cssChunking: 'strict' ile CSS chunk birleşmesini önle

  • Non-critical kodları requestIdleCallback ile ertele

  • PageSpeed Insights Field Data'sını düzenli kontrol et

Son güncelleme: Ağustos 2026
Kullanılan araçlar: Lighthouse CLI, Chrome DevTools, @next/bundle-analyzer, HAR analiz scripti, PageSpeed Insights

#Next.js#Web Vitals#Performance#Lighthouse#Case Study
GitHub
LinkedIn