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)
🔴 Kötü (Poor) seviyeden 🟢 Good sınırına geçiş
Main thread bloklama süresinde devasa düşüş
İlk büyük görsel ve metin çizim süresi
Layout shift sıfıra yaklaştırıldı
İçindekiler
- Başlangıç Noktası: Sayılar Neredeydi?
- Ölçüm Araçları: Ne Kullandım, Neden?
- Faz 1 — Düşmanı Tanı: Bundle Analizi ve HAR Takibi
- Faz 2 — 3. Parti Scriptlerin Taşınması: GTM ve Yandex Metrica → Partytown
- Faz 3 — Görsel Optimizasyon: LCP'yi Kırmak
- Faz 4 — Font Stratejisi: FOIT, FOUT ve CLS
- Faz 5 — CSS Disiplini: Critical CSS ve Chunk Stratejisi
- Faz 6 — JavaScript Bundle'ı İnceltiriz: Dynamic Import ve Lazy Loading
- Sonuç: Nereden Nereye?
- Öğrendiklerim ve Tavsiyeler
1. Başlangıç Noktası
Projeye başladığımda ilk Lighthouse taramasının sonuçları şöyleydi:
Mobil — İlk Durum (Baseline)
| Metrik | Değer | Durum |
|---|---|---|
| Performance Score | 35–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 Index | 3.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 CLI | Core Web Vitals, fırsat analizi | Local + CI |
| Chrome DevTools — Performance tab | Main thread flame chart, long tasks | Local dev |
| Chrome DevTools — Network tab | Request waterfall, transfer size | Local dev |
| HAR (HTTP Archive) dosyaları | Ağ akışının tam kaydı, analiz scripti | Production |
| PageSpeed Insights | Lab + Field data karşılaştırması | Production URL |
| @next/bundle-analyzer | JS chunk ağırlıkları, hangi paket nerede | Build time |
| Web Vitals (Chrome Extension) | Gerçek kullanıcı verisi, CrUX | Tarayıcı |
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çlu | Boyut | Sorun |
|---|---|---|
gtm.js (Google Tag Manager) | 155 KB | %56'sı kullanılmıyor, main thread'i bloklayan sync script |
Yandex Metrica (tag.js) | ~254 KB | Main thread'de 3–4s meşgul ediyor |
| ReactPlayer | 67 KB | Her sayfada yükleniyor, sadece detail sayfasında kullanılıyor |
| Unused CSS chunks | 76–72 KB | %70–100 kullanılmıyor, render blocking |
| Unused JS chunks | 146–318 KB | Critical 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.
- GTM scripti
<head>'de senkron yükleniyor → her şeyi blokluyor - Google Fonts
<link rel="stylesheet">render-blocking - Görsel
sizesattribute'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.
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 | Önce | Sonra | Fark |
|---|---|---|---|
| Performance Score | 45 | 54 | +9 |
| TBT | 3,666 ms | 2,070 ms | -1,596 ms |
| TTI | 27.0 s | 13.6 s | -13.4 s |
| 3. parti tracking CPU | ~3,900 ms | ~132 ms | -97% azalma |
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 | Önce | Sonra |
|---|---|---|
| LCP | 7.4 s | 5.0 s |
| CLS | 0.098 | 0.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ı
| Durum | Render-blocking time | CLS |
|---|---|---|
Google Fonts <link> | ~490–610 ms | 0.08+ |
next/font + display: swap + adjustFontFallback | 0 ms | 0.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ı?
| Kaynak | Katkı |
|---|---|
| Full Bootstrap (tüm bileşenler) | ~250+ KB derlenmiş |
| 5,545 unique selector | Çoğu o sayfada kullanılmıyor |
459 @media query | Devasa overhead |
52 @keyframes animasyonu | Büyük çoğunluğu ilk sayfada tetiklenmiyor |
| Swiper CSS | Global olarak eklenmiş |
| Blog sayfası stilleri | Ana sayfada yok |
| Arama sayfası stilleri | Ana sayfada yok |
| Detail sayfası stilleri | Ana 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:
| Durum | CSS Yükleme Stratejisi | İlk Yük |
|---|---|---|
| Eski Next.js | 1–2 MB tek dosya, sıralı | Çok yavaş |
| Yeni Next.js | Paralel chunk'lar, bağımsız indirme | Daha 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.
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ım | Yapılan | Etki |
|---|---|---|
| Next.js versiyonu yükselt | Monolitik CSS → paralel chunk'lar | İlk yüklemede ciddi kazanım |
cssChunking: 'strict' | Diğer sayfa CSS'lerinin büyük kısmı engellendi | Unused CSS %43 azaldı |
| Critical inline CSS | Beyaz ekran sorunu giderildi | FCP görsel olarak iyileşti |
| Bootstrap seçici import | Global @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
| Optimizasyon | Tasarruf | Etki |
|---|---|---|
| ReactPlayer lazy load | ~67 KB (arama sayfası) | TBT azalma |
| Bootstrap lazy load | ~32 KB | Parse time azalma |
| Navbar ayrı chunk | Ana bundle daha hızlı parse | LCP iyileşme |
| GTM duplicate kaldır | ~155 KB tekrarlı yükleme önlendi | TBT azalma |
requestIdleCallback | Non-critical işlemler ertelendi | TBT azalma |
9. Sonuç: Nereden Nereye?
Tüm fazların tamamlanmasından sonraki sonuçlar:
Mobil — Arama Sayfası
| Metrik | Başlangıç | Sonuç | Değişim |
|---|---|---|---|
| Performance Score | 35–46 | 70–80 | +34–44 puan |
| FCP | 2.8 s | 2.5 s | -300 ms |
| LCP | 7.4 s | 5.0 s | -2.4 s |
| TBT | 970 ms | 200 ms | -770 ms (-79%) |
| CLS | 0.098 | 0.013 | -87% |
| TTI | 9.1 s | 7.0 s | -2.1 s |
| Speed Index | 3.7 s | 2.5 s | -1.2 s |
Ana Sayfa (Production)
| Metrik | Değer | Durum |
|---|---|---|
| Performance Score | 78 | 🟡 Good sınırında |
| FCP | 1.8 s | 🟡 |
| LCP | 3.0 s | 🟡 |
| TBT | 590 ms | 🟡 |
| CLS | 0.034 | 🟢 |
Desktop Karşılaştırması
| Sayfa | Önce | Sonra |
|---|---|---|
| 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
- 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
forwardarray'i eksik olursa tracking bozulur. Her deployment sonrası event'lerin geldiğini doğrulayın. next/font+adjustFontFallbackCLS'yi neredeyse sıfırladı: Google Fonts<link>→next/fontgeçişi hem render-blocking'i kaldırdı hem de layout shift'i minimize etti.adjustFontFallback: trueözelliği göz ardı edilmemeli.sizesprop'u küçük görünür, büyük etki yapar:100vwile50vwarasındaki fark, mobilde indirilen görsel boyutunu ikiye katlar. Her Image bileşeninde gerçek render boyutuna göresizesvermek şart.- Dynamic import + loading placeholder ikili:
loading: () => <div style={{minHeight: X}} />ile placeholder vermek CLS'yi önler. Boşloading: () => nullkullanırsanız layout kayması olabilir. requestIdleCallbackile 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
- 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. - 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.
font-display: optionalCLS'yi önler ama FOIT yaratır: İlk etaptadisplay: 'optional'kullandım. Font geç gelince hiç gösterilmiyordu — bu da kötü UX.display: 'swap'+adjustFontFallbackkombinasyonu daha iyi.- 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:
@next/bundle-analyzerile bundle haritasını çıkarLighthouse CLI ile mobil baseline oluştur ve sakla
GTM / analytics scriptlerini Partytown'a taşı
next/fontkullan, Google Fonts<link>stylesheet'i kaldırHer
<Image>bileşenindesizesprop'unu gerçek render boyutuna göre verLCP elemanına
priorityekleTüm görsellere
decoding="async"ekleAğır bileşenler (navbar, modal, video) için
dynamic()import kullancssChunking: 'strict'ile CSS chunk birleşmesini önleNon-critical kodları
requestIdleCallbackile ertelePageSpeed 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

