Kenapa ini penting
Pengunjung tidak sabar. Halaman yang lama kosong, tombol yang tidak merespons saat ditekan, atau teks yang tiba-tiba melompat ke bawah karena gambar baru termuat, semuanya membuat orang menutup tab. Google juga mengukur hal-hal ini lewat Core Web Vitals dan memakainya sebagai salah satu sinyal peringkat pencarian. Jadi performa bukan sekadar "bonus", melainkan bagian dari pengalaman pengguna dan SEO.
Next.js sudah melakukan banyak optimasi otomatis: Server Component tidak mengirim JavaScript, kode dipecah per route, gambar dan font dioptimalkan. Tapi aplikasi tetap bisa lambat kalau kamu, misalnya, meng-import library chart 300 KB di halaman yang jarang dipakai, atau membuat seluruh halaman menunggu satu query database yang lambat. Modul ini membekalimu cara mengukur dulu, lalu memperbaiki bagian yang benar-benar bermasalah.
Analogi: optimasi tanpa pengukuran itu seperti diet tanpa timbangan. Kamu bisa saja berhenti makan nasi, padahal penyebab berat badan sebenarnya adalah minuman manis tiga gelas sehari. Ukur dulu (bundle analyzer, Web Vitals), baru tentukan apa yang dipangkas.
Konsep inti
Core Web Vitals
Tiga metrik utama yang diukur dari pengalaman pengguna sungguhan:
| Metrik | Mengukur | Target "good" (versi web.dev) |
|---|---|---|
| LCP (Largest Contentful Paint) | Kapan elemen terbesar (gambar hero, judul) tampil | ≤ 2,5 detik |
| INP (Interaction to Next Paint) | Seberapa cepat halaman merespons klik/ketik | ≤ 200 ms |
| CLS (Cumulative Layout Shift) | Seberapa banyak tata letak "melompat" | ≤ 0,1 |
Penyebab umum dan obatnya di Next.js: LCP lambat → gambar hero tidak diprioritaskan atau data utama menunggu lama (pakai next/image dengan preload, prerender, streaming). INP buruk → terlalu banyak JavaScript di main thread (kurangi Client Component, lazy load). CLS tinggi → gambar tanpa ukuran, font yang berganti mendadak (pakai next/image dengan width/height dan next/font).
Next.js menyediakan hook useReportWebVitals dari next/web-vitals untuk mengirim angka ini ke log atau layanan analitik. Setiap metrik punya name, value, dan rating ("good", "needs-improvement", atau "poor"). Catatan Next.js 16 untuk next/image: prop priority sudah deprecated dan diganti preload; dokumentasinya juga menyebut bahwa dalam banyak kasus loading="eager" atau fetchPriority="high" lebih tepat.
Menganalisis bundle
Sebelum memangkas, lihat apa isi JavaScript-mu. Next.js 16 menyediakan dua alat:
next analyze(tersedia sejak 16.1, khusus Turbopack, yang merupakan bundler default Next.js 16). Perintahnpx next analyzemenganalisis output tanpa membuat build aplikasi, lalu membuka UI interaktif di browser untuk memfilter modul dan melacak rantai import ("kenapa library ini ikut masuk?").npx next analyze --outputmenulis hasilnya ke.next/diagnostics/analyzesupaya bisa dibandingkan sebelum dan sesudah refactor.@next/bundle-analyzer(16.4.3), plugin untuk proyek yang build dengan Webpack. Dipasang dinext.config, lalu jalankanANALYZE=true npm run build.
Sejak Next.js 16, next build juga tidak lagi menampilkan ukuran bundle JS di ringkasan route, jadi analyzer inilah sumber angkanya.
Obat yang paling sering dipakai setelah menemukan modul besar: pindahkan logika ke Server Component (library hanya jalan di server, tidak terkirim), lazy load komponen yang tidak langsung terlihat, atau pakai opsi optimizePackageImports untuk library ikon/utilitas yang meng-export ratusan modul.
Dynamic import dengan next/dynamic
Lazy loading berarti menunda pemuatan kode sampai benar-benar dibutuhkan. next/dynamic adalah gabungan React.lazy() dan Suspense:
const Chart = dynamic(() => import('./Chart'), { loading: () => <p>Memuat grafik...</p> })
Hal yang perlu diingat (dari dokumentasi Next.js 16):
- Server Component sudah otomatis dipecah per route; lazy loading terutama berguna untuk Client Component.
- Opsi
ssr: false(komponen hanya dirender di browser, cocok untuk library yang memakaiwindow) hanya boleh dipakai di dalam Client Component. Di Server Component, opsi ini memicu error. - Kamu juga bisa memuat library biasa saat dibutuhkan dengan
await import('nama-library')di dalam event handler.
Streaming dengan Suspense
Tanpa streaming, server menunggu semua data selesai baru mengirim HTML. Dengan <Suspense>, server langsung mengirim bagian yang sudah siap beserta fallback (misalnya skeleton) untuk bagian yang masih menunggu, lalu "mengalirkan" sisanya begitu siap. File loading.js di sebuah folder route adalah cara pintas: Next.js otomatis membungkus page.js di folder itu dengan <Suspense fallback={<Loading />}>.
Prinsipnya: taruh pekerjaan async sedalam mungkin di pohon komponen. Kalau await lambat ada di page paling atas, seluruh halaman menunggu. Kalau dipindah ke komponen kecil di dalam <Suspense>, sisa halaman bisa tampil duluan.
Cache Components dan Partial Prerendering
Ini perubahan terbesar Next.js 16 soal performa, dan proyek baru sudah menyalakannya (cacheComponents: true). Dengan Cache Components:
- Pengambilan data dinamis secara default. Tidak ada lagi caching
fetchdiam-diam seperti di Next.js 14. Kamu memilih secara eksplisit apa yang di-cache dengan directive'use cache', ditemanicacheLife(berapa lama) dancacheTag(label untuk invalidasi). - Partial Prerendering (PPR) menjadi perilaku default. Saat build, Next.js merender setiap route dan menghasilkan static shell: bagian statis, hasil
'use cache', dan fallback<Suspense>. Shell ini bisa dilayani langsung dari CDN (instan), lalu bagian dinamis (cookie, data per pengguna, query yang tidak di-cache) di-stream saat request. Flag lamaexperimental.pprsudah dihapus. - Route segment config lama tidak berlaku.
export const dynamic = 'force-dynamic',revalidate, danfetchCachemenjadi error. Penggantinya: hapus saja (data sudah dinamis by default), atau pakai'use cache'+cacheLife. - Nilai acak dan waktu harus eksplisit.
Math.random(),Date.now(), ataunew Date()saat prerender memicu error. Bungkus dengan<Suspense>dan panggilawait connection()dulu (render per request), atau taruh di dalam'use cache'(nilai dibagikan semua pengunjung). - Validasi di dev. Kalau ada data yang belum di-cache di luar
<Suspense>, overlay dev menampilkan insight "blocking route" lengkap dengan saran perbaikan.
Hasilnya terlihat di output npm run build: ○ (Static) untuk route yang sepenuhnya prerender, ◐ (Partial Prerender) untuk route yang punya static shell plus bagian yang di-stream, dan ƒ (Dynamic) untuk route yang dirender per request.
Kalau kamu bekerja di proyek lama tanpa cacheComponents, model caching sebelumnya (dynamic, revalidate, fetch dengan next: { revalidate }) masih didokumentasikan di panduan "Caching and Revalidating (Previous Model)".
Contoh kode
Satu halaman yang menggabungkan tiga jenis konten: statis, di-cache, dan di-stream per request.
// app/dashboard-demo/page.js
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { connection } from 'next/server'
import { cacheLife, cacheTag } from 'next/cache'
// 1. Data publik di-cache: masuk static shell
async function PopularPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=3')
const posts = await res.json()
return (
<ul>
{posts.map((p) => <li key={p.id}>{p.title}</li>)}
</ul>
)
}
// 2. Data per pengguna (cookie): di-stream saat request
async function Greeting() {
const theme = (await cookies()).get('theme')?.value ?? 'terang'
return <p>Tema pilihanmu: {theme}</p>
}
// 3. Nilai yang harus unik per request: connection() dulu
async function ServerTime() {
await connection()
return <p>Dirender pada {new Date().toLocaleTimeString('id-ID')}</p>
}
export default function Page() {
return (
<main>
<h1>Dashboard</h1> {/* statis → static shell */}
<PopularPosts /> {/* di-cache → static shell */}
<Suspense fallback={<p>Memuat preferensi...</p>}>
<Greeting /> {/* di-stream */}
</Suspense>
<Suspense fallback={<p>...</p>}>
<ServerTime /> {/* di-stream */}
</Suspense>
</main>
)
}
// Hasil build: ◐ /dashboard-demo (Partial Prerender)
Lazy loading komponen berat yang hanya muncul setelah tombol diklik:
// app/statistik/StatsPanel.jsx
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
// Kode HeavyChart baru diunduh ketika pertama kali dirender
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Memuat grafik...</p>,
ssr: false, // boleh di sini karena file ini Client Component
})
export function StatsPanel({ data }) {
const [show, setShow] = useState(false)
return (
<div>
<button onClick={() => setShow(true)}>Tampilkan grafik</button>
{show && <HeavyChart data={data} />}
</div>
)
}
Skeleton otomatis untuk satu folder route:
// app/blog/loading.js
export default function Loading() {
return (
<div aria-busy="true">
<div style={{ height: 32, width: '60%', background: '#eee', borderRadius: 4 }} />
<div style={{ height: 16, width: '90%', background: '#eee', marginTop: 12 }} />
</div>
)
}
Mengukur Web Vitals di browser:
// app/_components/WebVitals.jsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
// → { name: 'LCP', value: 1234.5, rating: 'good', ... }
console.log(metric.name, Math.round(metric.value), metric.rating)
})
return null
}
// app/layout.js → taruh <WebVitals /> di dalam <body>
Menganalisis bundle dengan Turbopack:
npx next analyze # buka UI interaktif di browser
npx next analyze --output # simpan ke .next/diagnostics/analyze
cp -r .next/diagnostics/analyze ./analyze-sebelum-refactor
Gambar hero yang diprioritaskan dan font tanpa layout shift:
// app/page.js (potongan)
import Image from 'next/image'
export default function Home() {
return (
<Image
src="/hero.jpg"
alt="Ilustrasi blog"
width={1200}
height={600} // ukuran eksplisit → browser menyisakan ruang, CLS aman
preload // Next.js 16: pengganti prop priority yang deprecated
/>
)
}
Kesalahan umum
- Mengoptimasi tanpa mengukur. Jalankan
next analyzedan lihat Web Vitals dulu. Sering kali satu library besar adalah 80% masalahnya. - Membawa kebiasaan Next.js 14 ke proyek Cache Components.
export const dynamic = 'force-dynamic'danexport const revalidate = 60kini memicu error. Pakai'use cache'+cacheLife, atau biarkan dinamis di dalam<Suspense>. awaitlambat di komponen paling atas. Seluruh halaman jadi menunggu. Pindahkan ke komponen anak di dalam<Suspense>agar sisanya tampil duluan.- Memakai
ssr: falsedi Server Component. Next.js akan error. Pindahkandynamic(..., { ssr: false })ke file'use client'. - Menjadikan seluruh halaman Client Component hanya karena butuh satu tombol interaktif. Semua JavaScript-nya ikut terkirim. Jadikan hanya tombolnya Client Component, sisanya tetap Server Component.
Latihan
- Mudah, Tambahkan
loading.jsdi folderapp/blog/proyek akhir dengan skeleton sederhana, lalu buka halaman artikel di mode development dan amati fallback-nya. - Sedang, Pasang komponen
WebVitalsdi layout, buka beranda di browser, dan catat nilai LCP, CLS, serta INP (klik sesuatu untuk memicu INP). Mana yang ratingnya bukangood? - Menantang, Buat halaman
/acakyang menampilkan angka acak. Pertama tulisMath.random()langsung di page lalu jalankannpm run builddan baca errornya. Perbaiki dengan dua cara: (a) angka berbeda setiap request, (b) angka sama untuk semua pengunjung selama 1 menit.
Lihat solusi
// 1. app/blog/loading.js
export default function Loading() {
return <p aria-busy="true">Memuat artikel...</p>
}
// Catatan: halaman artikel proyek akhir sudah punya <Suspense> sendiri,
// jadi loading.js terutama terlihat saat navigasi antar halaman.
// 2. Contoh hasil di console (angka akan berbeda di mesinmu):
// FCP 180 good
// LCP 420 good
// CLS 0 good
// INP 56 good (muncul setelah ada interaksi)
// Ukur juga dengan throttling "Slow 4G" di DevTools untuk melihat kondisi pengguna nyata.
// 3a. Berbeda setiap request
import { Suspense } from 'react'
import { connection } from 'next/server'
async function RandomNumber() {
await connection() // tunda ke waktu request
return <p>Angka: {Math.random()}</p>
}
export default function Page() {
return (
<Suspense fallback={<p>...</p>}>
<RandomNumber />
</Suspense>
)
}
// 3b. Sama untuk semua pengunjung selama ±1 menit
import { cacheLife } from 'next/cache'
async function CachedRandom() {
'use cache'
cacheLife('minutes') // profil minutes: revalidate 1 menit
return <p>Angka: {Math.random()}</p>
}
export default function Page() {
return <CachedRandom />
}
Ringkasan
- Core Web Vitals (LCP, INP, CLS) mengukur pengalaman nyata; pantau dengan
useReportWebVitals. - Ukur bundle dengan
next analyze(Turbopack, Next.js 16.1+) atau@next/bundle-analyzer(Webpack) sebelum memangkas. next/dynamicmenunda kode Client Component;ssr: falsehanya boleh di Client Component.<Suspense>danloading.jsmengalirkan bagian lambat tanpa menahan seluruh halaman.- Dengan Cache Components (default proyek baru), PPR otomatis: static shell dari bagian statis dan
'use cache', sisanya di-stream;dynamic/revalidatelama tidak dipakai lagi.