Saya menghabiskan sore hari mengimplementasikan dark mode di matthewswong.com — dan kemudian sore hari lagi memperbaiki flash tema yang salah saat muat halaman. Masalah inti: server merender HTML tanpa mengetahui preferensi tema pengguna, browser memuat HTML dan menampilkannya, kemudian JavaScript terhidrasi dan beralih ke tema yang benar. Saat antara tampilan dan hidrasi itu menampilkan tema yang salah — sebuah flash yang terlihat tidak profesional. Solusinya memerlukan skrip pemblokir yang membaca preferensi sebelum tampilan pertama. next-themes menangani ini dengan benar secara out of the box.
Dark mode tampaknya sederhana: toggle CSS class dan tukar warna. Kompleksitas datang dari tiga persyaratan yang bekerja bersama: pertahankan pilihan pengguna di seluruh kunjungan (localStorage), hormati preferensi sistem tingkat OS (media query prefers-color-scheme), dan hindari flash tema yang salah saat muat halaman (masalah FOUC). Tantangan server-side rendering adalah fundamental: server tidak mengetahui preferensi tema klien.
Media query CSS `prefers-color-scheme: dark` mendeteksi preferensi dark mode tingkat OS pengguna. Di macOS, ini mencerminkan System Preferences > Appearance. Di Windows, ini adalah Settings > Personalization > Colors. Di iOS dan Android, ini adalah mode tampilan sistem. Tanpa JavaScript, Anda dapat menangani ini di CSS saja: `@media (prefers-color-scheme: dark) { :root { --bg: #0a0a0a; } }`.
next-themes (github.com/pacocoursey/next-themes) adalah solusi dark mode yang paling banyak digunakan untuk Next.js. Ini menyuntikkan skrip pemblokir kecil di `<head>` yang mengeksekusi sebelum CSS atau konten halaman apa pun dirender. Skrip membaca tema dari localStorage, memeriksa media query prefers-color-scheme, dan menerapkan kelas yang benar ke elemen `<html>` sebelum tampilan pertama browser. Ini menghilangkan flash.
Dark Mode — The Flash Problem and Fix:
────────────────────────────────────────────────────────────
WITHOUT next-themes (FOUC — Flash of Unstyled Content):
1. Server renders HTML (no theme class)
2. Browser paints: white background (default) ← FLASH visible
3. React hydrates → reads localStorage
4. Theme class added to <html>
5. CSS updates → switches to dark ← visible flash!
WITH next-themes (no flash):
1. Server renders HTML (no theme class)
2. next-themes injects blocking <script> in <head>:
(function() {
var theme = localStorage.getItem('theme') || 'system';
if (theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})()
3. Script runs SYNCHRONOUSLY before first paint
4. .dark class added before browser renders anything ← no flash!
5. Browser paints with dark styles already applied
6. React hydrates → no visual change
Theme priority:
Manual override (localStorage) > System preference (prefers-color-scheme)Dari pengalaman saya mengimplementasikan dark mode di matthewswong.com: gunakan CSS custom properties (variabel) untuk semua warna daripada modifier class `dark:` Tailwind. Variabel CSS diperbarui secara instan saat kelas html berubah — tidak perlu re-render. Definisikan palet warna lengkap Anda sebagai variabel CSS di :root (tema terang) dan dalam .dark:root atau [data-theme='dark'] (tema gelap). Komponen menggunakan var(--bg-primary), var(--text-primary) dll., dan pergantian tema instan tanpa JavaScript yang terlibat dalam komponen.
Menyiapkan next-themes di Next.js App Router memerlukan pembungkusan aplikasi dalam ThemeProvider dalam komponen klien (karena ThemeProvider menggunakan React context, yang sisi klien). Buat komponen Providers yang merupakan komponen klien, bungkus children Anda dalam ThemeProvider, dan impor komponen Providers ini di root layout Anda.
Theme toggle membaca tema saat ini dengan `useTheme()` dari next-themes dan bersiklus melalui light/dark/system. Hook useTheme mengembalikan: theme (pengaturan aktif: 'light', 'dark', atau 'system'), resolvedTheme (tema yang benar-benar ditampilkan: 'light' atau 'dark', mempertimbangkan preferensi sistem), dan setTheme (untuk mengubah preferensi). Gunakan `resolvedTheme` untuk menentukan ikon mana yang ditampilkan — jangan gunakan `theme` karena 'system' tidak langsung dipetakan ke ikon.
// npm install next-themes
// app/providers.tsx — client component wrapper
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class" // adds .dark class to <html>
defaultTheme="system" // follow OS preference by default
enableSystem={true} // detect prefers-color-scheme
disableTransitionOnChange // prevent flash during transition
storageKey="theme" // localStorage key
>
{children}
</ThemeProvider>
)
}
// app/layout.tsx — wrap everything in Providers
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
{/* suppressHydrationWarning prevents hydration mismatch warning */}
{/* next-themes adds/removes class before React hydrates */}
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// tailwind.config.ts
export default {
darkMode: 'class', // .dark class toggles dark mode
content: ['./app/**/*.{ts,tsx}'],
}
// components/theme-toggle.tsx
'use client'
import { useTheme } from 'next-themes'
import { Moon, Sun, Monitor } from 'lucide-react'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Must wait for mount to avoid hydration mismatch
useEffect(() => setMounted(true), [])
if (!mounted) return <div className="w-9 h-9" /> // placeholder
return (
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
{resolvedTheme === 'dark'
? <Sun className="w-5 h-5" />
: <Moon className="w-5 h-5" />}
</button>
)
}
// globals.css — CSS variables approach (faster than Tailwind dark: classes)
:root {
--bg-primary: #ffffff;
--text-primary: #0f172a;
--accent: #3b82f6;
}
.dark {
--bg-primary: #0a0a0a;
--text-primary: #f1f5f9;
--accent: #60a5fa;
}Untuk Tailwind CSS, atur `darkMode: 'class'` di tailwind.config.ts. Ini mengaktifkan varian `dark:`, yang menerapkan styles hanya ketika kelas `.dark` ada di elemen html. Pasangkan ini dengan next-themes menggunakan `attribute='class'` — next-themes akan menambah/menghapus kelas `.dark` di html. Kemudian gunakan utilitas Tailwind: `bg-white dark:bg-slate-900`, `text-slate-900 dark:text-slate-100`.
Kesalahan umum: merender ikon matahari/bulan berdasarkan `theme` sebelum komponen dipasang, menyebabkan ketidakcocokan hidrasi karena server merender default (terang) sementara klien merender preferensi aktual pengguna (gelap). Perbaikan: gunakan status `mounted` yang dimulai sebagai false dan menjadi true di useEffect. Sebelum pemasangan, render placeholder netral. Setelah pemasangan, render indikator tema aktual.
Aliran untuk pengguna yang kembali: 1) Browser meminta halaman. 2) Server merender HTML netral (belum ada kelas tema). 3) HTML dikirim ke browser. 4) Skrip pemblokir next-themes berjalan di <head>, membaca localStorage, dan menambahkan kelas .dark ke <html>. 5) Browser menampilkan dengan styles gelap yang sudah diterapkan. 6) React terhidrasi — tidak ada perubahan visual karena kelas sudah diatur.
Jika pengguna mengubah tema OS mereka sementara halaman Anda terbuka (skenario umum pada laptop di mana pengguna mengaktifkan dark mode di malam hari melalui penjadwalan otomatis), aplikasi Anda harus memperbarui secara dinamis. next-themes menangani ini dengan listener `window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)`. Ketika preferensi berubah, jika pengguna belum menetapkan override manual, next-themes memperbarui tema yang diselesaikan.