Flicker UI di Next.js App Router biasanya terjadi ketika hasil render awal dari server tidak sama dengan state yang akhirnya dipakai di browser setelah hydration. Kasus yang paling sering: status auth, tema gelap/terang, locale, atau preferensi layout ditentukan dari cookie atau header request, tetapi komponen client baru membacanya setelah halaman tampil.

Akibatnya, pengguna melihat UI yang sempat salah selama sepersekian detik: tombol Login berubah menjadi Logout, tema terang mendadak berganti ke gelap, atau menu user muncul terlambat. Solusi yang paling stabil adalah memastikan server dan client memulai dari state yang sama: baca cookie di server, render markup awal sesuai state tersebut, lalu operkan initial state ke komponen client bila interaktivitas tetap diperlukan.

Gejala yang Terlihat Pengguna

Masalah ini sering terlihat seperti bug kecil, padahal sumbernya ada pada alur render Next.js:

  • Flash tema: halaman tampil terang lalu berubah ke gelap.
  • Flash status auth: navbar awalnya menampilkan tombol login, lalu berubah menjadi avatar user.
  • Hydration mismatch warning: React memberi peringatan karena markup server dan client tidak cocok.
  • Layout shift: elemen berpindah posisi setelah state client selesai dimuat.

Pada App Router, masalah ini lebih mudah muncul jika Anda mencampur Server Component dan Client Component tanpa memikirkan asal state pertama kali.

Root Cause Teknis: SSR, Hydration, dan Cookie

Yang Terjadi Saat Request Masuk

Dalam SSR, server membentuk HTML awal berdasarkan data yang tersedia saat request diproses. Pada Next.js App Router, data seperti cookie dan header request tersedia di server. Browser lalu menerima HTML ini dan menampilkannya segera.

Setelah itu, React di client melakukan hydration: event listener dipasang, komponen client aktif, lalu state client dihitung. Jika state awal di client berbeda dari yang dipakai server saat membuat HTML, maka UI akan berubah setelah hydration.

Kenapa Cookie Sering Menjadi Pemicu

Cookie sering dibaca terlambat, misalnya hanya melalui document.cookie, library client-side, atau useEffect. Masalahnya, pada saat server merender, state ini belum dipakai. Maka server menghasilkan fallback seperti:

  • pengguna dianggap belum login, atau
  • tema dianggap light.

Lalu di browser, efek client membaca cookie sebenarnya:

  • pengguna ternyata login, atau
  • tema sebenarnya dark.

Hasilnya adalah perbedaan markup awal dan state akhir.

Pola yang Salah dan Kenapa Menyebabkan Flicker

1. Membaca Auth Hanya di Client

'use client'

import { useEffect, useState } from 'react'

export default function Navbar() {
  const [isLoggedIn, setIsLoggedIn] = useState(false)

  useEffect(() => {
    const hasToken = document.cookie.includes('session=')
    setIsLoggedIn(hasToken)
  }, [])

  return (
    <nav>
      {isLoggedIn ? <button>Logout</button> : <button>Login</button>}
    </nav>
  )
}

Masalahnya: server selalu merender Login karena useEffect tidak berjalan di server. Setelah hydration, tombol berubah menjadi Logout.

2. Menentukan Tema Setelah Mount

'use client'

import { useEffect, useState } from 'react'

export default function ThemeWrapper({ children }) {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    const isDark = document.cookie.includes('theme=dark')
    setTheme(isDark ? 'dark' : 'light')
  }, [])

  return <div data-theme={theme}>{children}</div>
}

Ini menyebabkan flash of incorrect theme. UI pertama tampil dengan light, baru kemudian berubah ke dark.

3. Fallback Server Tidak Sama dengan Initial State Client

Bahkan jika Anda tidak membaca cookie di useEffect, mismatch tetap bisa terjadi bila initial state client dihitung dari sumber berbeda dibanding render server.

Prinsip dasarnya sederhana: HTML awal harus dibuat dari sumber state yang sama dengan state awal React di client.

Pendekatan yang Lebih Stabil di App Router

Baca Cookie di Server dengan cookies()

Pada App Router, pendekatan yang aman adalah membaca cookie di server, biasanya di layout, page, atau Server Component lain. Kemudian render UI awal langsung berdasarkan nilai itu.

import { cookies } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value === 'dark' ? 'dark' : 'light'

  return (
    <main data-theme={theme}>
      <h1>Dashboard</h1>
    </main>
  )
}

Dengan cara ini, HTML awal yang dikirim ke browser sudah sesuai dengan request pengguna saat itu.

Pass Initial State ke Client Component

Jika komponen tetap butuh interaktivitas client, jangan biarkan ia menebak state dari nol. Oper nilai awal dari server.

import { cookies } from 'next/headers'
import NavbarClient from './NavbarClient'

export default async function Navbar() {
  const cookieStore = await cookies()
  const isLoggedIn = Boolean(cookieStore.get('session')?.value)

  return <NavbarClient initialIsLoggedIn={isLoggedIn} />
}
'use client'

import { useState } from 'react'

export default function NavbarClient({ initialIsLoggedIn }) {
  const [isLoggedIn, setIsLoggedIn] = useState(initialIsLoggedIn)

  return (
    <nav>
      {isLoggedIn ? <button>Logout</button> : <button>Login</button>}
    </nav>
  )
}

Kenapa ini bekerja? Karena render pertama di client memakai nilai yang sama dengan server. Tidak ada perubahan mendadak tepat setelah hydration.

Gunakan Server Component untuk Bagian yang Murni Bergantung pada Request

Jika sebuah blok UI hanya bergantung pada cookie/header dan tidak perlu interaksi browser, lebih baik tetap sebagai Server Component sepenuhnya.

import { cookies } from 'next/headers'

export default async function UserMenu() {
  const cookieStore = await cookies()
  const user = cookieStore.get('session')?.value

  if (!user) {
    return <a href="/login">Login</a>
  }

  return <a href="/account">Akun Saya</a>
}

Ini mengurangi peluang mismatch karena state tidak dihitung ulang di client.

Contoh Praktis: Tema Tanpa Flicker

Kasus tema adalah contoh klasik karena perubahan visualnya sangat mudah terlihat.

Layout Server Membaca Cookie

import { cookies } from 'next/headers'
import ThemeProvider from './ThemeProvider'

export default async function RootLayout({ children }) {
  const cookieStore = await cookies()
  const initialTheme = cookieStore.get('theme')?.value === 'dark' ? 'dark' : 'light'

  return (
    <html lang="id" data-theme={initialTheme}>
      <body>
        <ThemeProvider initialTheme={initialTheme}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Provider Client Memakai Initial State yang Sama

'use client'

import { createContext, useMemo, useState } from 'react'

export const ThemeContext = createContext(null)

export default function ThemeProvider({ initialTheme, children }) {
  const [theme, setTheme] = useState(initialTheme)

  const value = useMemo(() => ({ theme, setTheme }), [theme])

  return (
    <ThemeContext.Provider value={value}>
      <div data-theme={theme}>{children}</div>
    </ThemeContext.Provider>
  )
}

Jika pengguna mengganti tema, Anda bisa memperbarui state client sekaligus menyimpan cookie baru melalui route handler atau aksi server yang sesuai. Yang penting, render pertama tetap sinkron dengan cookie request awal.

Kapan Perlu Guard Render

Ada kasus di mana state memang hanya tersedia di browser, misalnya bergantung pada API browser tertentu atau storage lokal yang tidak direplikasi ke server. Dalam situasi ini, guard render bisa dipakai untuk menahan bagian UI tertentu sampai client siap.

'use client'

import { useEffect, useState } from 'react'

export default function ClientOnlyWidget() {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return <div>Widget khusus browser</div>
}

Namun ini bukan solusi utama untuk auth atau tema jika sumber state sebenarnya sudah tersedia di request melalui cookie/header. Guard render hanya menyembunyikan mismatch dengan menunda render, tetapi tidak memperbaiki arsitektur data flow.

Kapan Guard Render Masuk Akal

  • Komponen benar-benar bergantung pada window, matchMedia, atau API browser lain.
  • Anda lebih memilih placeholder kosong/skeleton daripada UI salah sesaat.
  • Bagian tersebut bukan elemen penting untuk first paint.

Langkah Debugging untuk Menemukan Sumber Flicker

1. Bandingkan Render Server dan State Client Awal

Cek apakah HTML awal memang sudah merepresentasikan state yang benar. Bila navbar server menampilkan Login padahal request membawa cookie sesi valid, akar masalahnya ada sebelum hydration.

2. Cari Pembacaan Cookie yang Terlambat

Periksa apakah ada pembacaan state di:

  • useEffect
  • document.cookie
  • library client-only
  • localStorage sebagai sumber utama state awal

Jika iya, tanyakan: apakah data ini sebenarnya sudah tersedia di server?

3. Perhatikan Warning Hydration

Di console browser, warning seperti text content does not match server-rendered HTML adalah sinyal kuat bahwa markup awal dan hasil client berbeda.

4. Uji dengan JavaScript Lambat

Throttle jaringan atau CPU di DevTools. Flicker yang tadinya cepat akan lebih mudah terlihat ketika hydration melambat.

5. Log Nilai di Server dan Client

Log cookie atau state di dua sisi untuk memastikan nilainya benar-benar sama pada render pertama.

// server component
import { cookies } from 'next/headers'

export default async function Page() {
  const cookieStore = await cookies()
  console.log('server theme:', cookieStore.get('theme')?.value)

  return null
}
'use client'

import { useEffect } from 'react'

export default function DebugClient({ initialTheme }) {
  useEffect(() => {
    console.log('client initialTheme:', initialTheme)
  }, [initialTheme])

  return null
}

Jika nilai server dan client berbeda sejak awal, Anda sudah menemukan penyebab utamanya.

Trade-off: UX, Cache, dan Performa SSR

UX Lebih Stabil, Tetapi Render Menjadi Lebih Personal

Membaca cookie di server meningkatkan konsistensi UI, tetapi artinya halaman atau segmen tertentu menjadi tergantung pada request pengguna. Ini sering mengurangi peluang caching penuh yang sama untuk semua pengguna.

Dampak terhadap Caching

Ketika output bergantung pada cookie seperti session atau theme, HTML yang dihasilkan bisa berbeda per pengguna atau per preferensi. Trade-off-nya:

  • Pro: tidak ada flicker atau mismatch awal.
  • Kontra: cache bersama menjadi kurang efektif untuk bagian yang dipersonalisasi.

Pendekatan praktisnya adalah mempersonalisasi hanya bagian yang memang perlu, bukan seluruh halaman bila tidak dibutuhkan.

Dampak terhadap Performa

Secara umum, membaca cookie request sendiri bukan operasi berat. Namun personalisasi di server bisa memengaruhi strategi rendering dan caching keseluruhan. Karena itu:

  • pakai Server Component untuk data request-scoped yang benar-benar penting bagi HTML awal,
  • batasi Client Component pada area interaktif,
  • hindari memindahkan seluruh layout ke client hanya untuk membaca satu cookie.

Kesalahan Umum yang Sering Terjadi

  • Mengandalkan localStorage untuk state awal auth/tema padahal server butuh tahu state tersebut saat SSR.
  • Menyetel default state yang salah dan berharap useEffect segera memperbaikinya.
  • Membuat seluruh navbar atau layout menjadi client-only padahal cukup oper initial state dari server.
  • Mencampur banyak sumber kebenaran: cookie, context client, dan query API yang tidak sinkron.
  • Menyembunyikan semua UI sampai mount untuk menghindari warning, tetapi mengorbankan UX awal secara berlebihan.

Checklist Pencegahan Flicker UI dari Cookie dan SSR

  1. Tentukan apakah state awal berasal dari request atau dari browser saja.
  2. Jika berasal dari request, baca di server dengan cookies() atau header terkait.
  3. Render HTML awal sesuai nilai tersebut.
  4. Jika komponen harus interaktif, oper initial state ke Client Component.
  5. Pastikan initial state client sama persis dengan hasil render server pertama.
  6. Gunakan guard render hanya untuk state yang memang tidak tersedia di server.
  7. Uji halaman dengan throttling agar flicker yang halus tetap terdeteksi.
  8. Periksa warning hydration di console selama development.

Penutup

Masalah Next.js flicker UI dari cookie dan SSR App Router hampir selalu berakar pada satu hal: server merender satu state, lalu client memulai dari state lain setelah hydration. Selama sumber data seperti auth atau tema tersedia di request, solusi yang benar bukan membaca status itu belakangan di browser, melainkan menggunakannya sejak render server.

Pola paling aman adalah: baca cookie di Server Component, render HTML awal berdasarkan nilai tersebut, lalu teruskan initial state ke komponen client hanya jika interaktivitas diperlukan. Dengan begitu, Anda menghilangkan mismatch awal, mengurangi layout shift, dan memberi pengalaman yang lebih stabil tanpa harus menutup-nutupi bug dengan loading atau render kosong.