Bug hydration auth di Next.js sering terasa membingungkan karena unit test tampak hijau, tetapi UI di browser tetap salah. Polanya hampir selalu sama: status autentikasi yang dipakai server saat merender HTML tidak identik dengan status yang dipakai client ketika hydration dan efek berjalan.
Masalah ini biasanya tidak muncul saat komponen diuji terpisah, karena unit test jarang mereplikasi kombinasi SSR output, cookie nyata, refresh token, navigasi browser, dan timing hydration. Jika aplikasi Anda pernah menampilkan tombol Login untuk user yang sebenarnya sudah masuk, mem-flash halaman privat sesaat, atau redirect terlalu lambat setelah hydration, kemungkinan besar akar masalahnya ada di batas antara server dan client, bukan di satu fungsi auth saja.
Konteksnya sejalan dengan tren diskusi engineering di komunitas seperti dev.to/shubhradev: bug modern bukan selalu bug algoritma, tetapi bug sinkronisasi antar-lapisan render, state, dan network.
Mengapa bug ini lolos unit test?
Unit test umumnya memverifikasi satu komponen atau satu hook dalam kondisi terkontrol. Sementara itu, hydration auth adalah masalah sistemik yang melibatkan:
- HTML awal dari server
- State auth awal di client
- Cookie yang bisa terbaca di server tetapi tidak selalu tersedia dengan cara yang sama di client
- Efek seperti refresh token atau fetch
/meyang berjalan setelah mount - Redirect router yang terjadi setelah render awal
Akibatnya, test sederhana seperti “jika user ada, tampilkan avatar” tidak cukup. Yang perlu diuji adalah apakah state awal server, state awal client, dan efek setelah hydration benar-benar konsisten.
Pola 1: Mismatch UI karena status login awal berbeda
Gejala
- Console menampilkan peringatan hydration mismatch.
- Server merender navbar untuk user tamu, tetapi setelah client mount navbar berubah menjadi versi user login.
- Tombol Masuk muncul sesaat lalu berubah menjadi avatar akun.
Root cause
Server dan client memulai render dengan sumber kebenaran yang berbeda. Contoh yang umum:
- Server tidak mengirim snapshot auth awal ke client.
- Client membaca token dari
localStorageatau memanggil endpoint profil setelah mount. - Server hanya tahu cookie, tetapi client memulai state dengan nilai default
unauthenticated.
Contoh alur
- User punya sesi valid di cookie.
- Server merender halaman tetapi komponen client memulai state dengan
user = null. - HTML awal berisi UI guest.
- Setelah hydration, hook auth memanggil
/api/medan mengubah state menjadi login. - React mendeteksi hasil render awal client tidak cocok dengan HTML server, atau setidaknya UI tampak berubah mendadak.
Perbaikan: snapshot state server
Prinsip dasarnya: jangan biarkan client menebak status auth awal jika server sudah tahu jawabannya. Ambil status sesi di server, lalu kirim snapshot awal ke provider client.
// Server component / layout / page (contoh pola, bukan API yang dipatok versi tertentu)
import { cookies } from 'next/headers';
async function getAuthSnapshot() {
const cookieStore = cookies();
const session = cookieStore.get('session');
if (!session) {
return { status: 'guest', user: null };
}
// Verifikasi sesi / ambil profil minimal di server
const user = await fetchUserFromSession(session.value);
return user
? { status: 'authenticated', user: { id: user.id, name: user.name } }
: { status: 'guest', user: null };
}
export default async function RootLayout({ children }) {
const authSnapshot = await getAuthSnapshot();
return (
<html>
<body>
<AuthProvider initialAuth={authSnapshot}>
{children}
</AuthProvider>
</body>
</html>
);
}Dengan pola ini, render pertama di client memakai state yang sama dengan render server, sehingga peluang mismatch jauh berkurang.
Pola 2: Flash konten salah sebelum status auth final diketahui
Gejala
- Halaman dashboard sempat menampilkan konten privat sebelum akhirnya redirect ke login.
- Atau kebalikannya, halaman sempat menampilkan skeleton/tamu lalu berubah ke konten user.
- Bug hanya terlihat di koneksi lambat atau perangkat tertentu.
Root cause
Komponen merender UI sensitif sebelum status auth final tersedia. Ini biasanya terjadi ketika aplikasi punya tiga status logis, tetapi kode hanya memperlakukan dua:
loadingauthenticatedguest
Jika status loading diabaikan, maka komponen akan memilih salah satu cabang terlalu cepat. Inilah sumber flash of wrong content.
Contoh anti-pattern
'use client';
export function ProtectedPage() {
const { user } = useAuth();
if (!user) {
return <LoginPrompt />;
}
return <SecretDashboard />;
}Masalahnya, !user bisa berarti dua hal: memang guest, atau status belum selesai dimuat.
Perbaikan: guard render yang eksplisit
'use client';
export function ProtectedPage() {
const { status, user } = useAuth();
if (status === 'loading') {
return <PageSkeleton />;
}
if (status === 'guest') {
return <LoginPrompt />;
}
return <SecretDashboard user={user} />;
}Mengapa ini bekerja? Karena Anda memisahkan kondisi “belum tahu” dari “sudah tahu tetapi tidak login”. Ini bukan hanya memperbaiki UX, tetapi juga mencegah kebocoran UI yang seharusnya tidak pernah muncul.
Trade-off-nya, ada kemungkinan skeleton tampil sedikit lebih lama. Namun untuk area yang sensitif, itu lebih aman daripada menampilkan konten yang salah meskipun hanya 200 ms.
Pola 3: Race condition saat refresh token
Gejala
- Request pertama setelah reload gagal 401, request berikutnya sukses.
- User tiba-tiba logout sendiri saat membuka banyak tab.
- Status auth bolak-balik antara login dan guest.
Root cause
Refresh token dan fetch data user berjalan bersamaan tanpa koordinasi. Misalnya:
- Client mount.
- Hook A memanggil
/api/me. - Interceptor atau hook B mendeteksi access token kedaluwarsa lalu memulai refresh.
/api/mekeburu gagal 401 dan state diubah ke guest.- Refresh selesai, token baru tersedia, tetapi UI sudah masuk jalur logout atau redirect.
Bug ini sering lolos unit test karena test tidak mensimulasikan waktu antar-request, retry, dan status token yang berubah di tengah jalan.
Perbaikan: satu jalur refresh, request menunggu hasilnya
Hindari refresh token paralel. Gunakan satu promise bersama untuk semua request yang membutuhkan token baru.
let refreshPromise = null;
async function ensureFreshSession() {
if (!refreshPromise) {
refreshPromise = refreshSession()
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function authFetch(input, init) {
let res = await fetch(input, init);
if (res.status !== 401) return res;
await ensureFreshSession();
return fetch(input, init);
}Pola ini tidak menyelesaikan semua kasus, tetapi mengurangi kondisi balapan yang paling umum: banyak request mencoba refresh bersamaan dan saling menimpa state.
Hal yang perlu diperhatikan
- Jika refresh gagal, pastikan semua caller menerima hasil gagal yang konsisten.
- Jangan langsung menandai user sebagai guest hanya dari satu 401 jika sistem Anda memang mendukung refresh otomatis.
- Bedakan 401 karena token kedaluwarsa dari 401 karena sesi benar-benar tidak valid, jika backend Anda menyediakan pembedaan itu.
Pola 4: Perbedaan cookie antara server dan client
Gejala
- SSR menganggap user login, tetapi client menganggap guest.
- Atau sebaliknya, client punya token di storage lama sementara server tidak punya sesi aktif.
- Bug hanya terjadi di production, subdomain tertentu, atau setelah deploy dengan domain berbeda.
Root cause
Server dan client tidak melihat sumber auth yang sama. Beberapa penyebab umum:
- Server mengandalkan cookie HTTP-only, client mengandalkan
localStorage. - Cookie tidak terkirim pada request tertentu karena domain/path/kebijakan browser.
- Client memanggil API tanpa credentials yang diperlukan untuk membawa cookie sesi.
- Ada dua sumber state: satu dari cookie, satu dari storage, dan keduanya bisa berbeda.
Prinsip perbaikan
- Pilih satu sumber kebenaran utama untuk sesi.
- Jika memakai cookie HTTP-only, jadikan server sebagai otoritas awal auth.
- Client sebaiknya menerima snapshot auth dari server, bukan membangun asumsi sendiri dari storage terpisah.
Common mistake
Menyimpan token akses di localStorage untuk client, tetapi memakai cookie sesi untuk SSR. Secara teknis bisa berjalan, tetapi sering melahirkan state ganda yang sulit disinkronkan. Jika arsitektur menuntut pemisahan itu, pastikan ada prosedur sinkronisasi yang eksplisit dan dapat diuji.
Pola 5: Redirect terlambat setelah hydration
Gejala
- User yang belum login sempat melihat halaman privat sebelum diarahkan ke login.
- Redirect terjadi setelah komponen dan data berat sempat dirender.
- SEO atau analitik menangkap page view yang tidak seharusnya.
Root cause
Proteksi rute dilakukan terlalu jauh di sisi client, biasanya di useEffect setelah halaman terlanjur dirender.
'use client';
export default function BillingPage() {
const { status } = useAuth();
const router = useRouter();
useEffect(() => {
if (status === 'guest') {
router.replace('/login');
}
}, [status, router]);
return <SensitiveBillingUI />;
}Kode di atas tampak masuk akal, tetapi redirect baru terjadi setelah render client, bukan sebelum HTML dikirim atau sebelum halaman dibangun di client.
Perbaikan: lakukan proteksi sedini mungkin
Jika server sudah bisa mengetahui status auth, lakukan redirect di server untuk halaman yang memang privat. Jika sebagian UI harus tetap client-only, letakkan boundary yang memastikan komponen sensitif tidak dirender sebelum status aman diketahui.
// Pseudocode server-side protection
export default async function ProtectedPage() {
const auth = await getAuthSnapshot();
if (auth.status !== 'authenticated') {
redirect('/login');
}
return <ProtectedClientShell initialAuth={auth} />;
}Jika redirect memang harus client-side, minimal jangan render konten sensitif sebelum keputusan auth final dibuat.
Pola implementasi yang paling aman
1. Snapshot state server sebagai baseline
Server membaca sesi dari cookie, memverifikasi seperlunya, lalu mengirim snapshot minimal ke client provider. Snapshot ini sebaiknya berisi data yang cukup untuk render awal, misalnya:
- status auth
- id user
- nama atau role jika benar-benar dibutuhkan UI awal
Jangan kirim data sensitif berlebihan hanya demi menghemat satu request.
2. Guard render untuk membedakan loading, guest, authenticated
Ini penting pada komponen privat, navbar, dan area yang paling sering memunculkan flash UI salah.
3. Boundary client-only untuk bagian yang memang bergantung pada browser
Jika ada komponen yang membutuhkan API browser seperti localStorage, ukuran viewport, atau SDK pihak ketiga, jangan paksa ia menentukan auth awal. Bungkus sebagai area client-only yang tidak mengontrol keputusan auth inti.
4. Hindari dua sumber auth yang setara
Pilih satu otoritas: biasanya cookie/sesi di server. Client boleh menyimpan cache turunan, tetapi bukan sumber kebenaran yang menyaingi server.
5. Koordinasikan refresh token
Refresh harus idempoten dari sudut pandang UI: satu proses refresh, banyak requester menunggu hasil yang sama, dan transisi status yang tidak saling bertabrakan.
Checklist diagnosis bug hydration auth di Next.js
Saat bug sulit direproduksi, gunakan checklist berikut:
- Apakah HTML awal dari server mewakili user login atau guest?
- Apakah state awal provider auth di client identik dengan hasil server?
- Apakah ada pembacaan
localStorageatau token browser yang mengubah status setelah mount? - Apakah komponen privat punya status
loadingyang eksplisit? - Apakah request
/mebisa balapan dengan refresh token? - Apakah cookie yang dipakai SSR juga benar-benar terkirim pada request API dari browser?
- Apakah redirect dilakukan di server, middleware, atau baru di
useEffect? - Apakah UI sensitif sempat dirender sebelum status auth final diketahui?
- Apakah bug hanya terjadi di tab kedua, reload keras, atau koneksi lambat?
- Apakah log menunjukkan status berubah dari authenticated → guest → authenticated dalam satu siklus load?
Strategi debugging yang praktis
Instrumentasi timeline auth
Tambahkan logging terstruktur untuk tiga fase:
- Server menentukan snapshot auth
- Client provider inisialisasi state
- Efek setelah mount: fetch profil, refresh token, redirect
Bukan sekadar console.log(user), tetapi log yang menunjukkan urutan peristiwa dan sumber keputusan.
// Contoh sederhana
logAuth('server_snapshot', snapshot);
logAuth('client_init', initialAuth);
logAuth('me_request_start');
logAuth('refresh_start');
logAuth('refresh_success');
logAuth('status_change', { from, to });Throttle network saat reproduksi
Banyak race condition baru terlihat saat latency diperbesar. Uji pada koneksi lambat dan reload berulang. Jika bug hanya terjadi sesekali, besar kemungkinan masalahnya memang di timing.
Uji banyak tab
Refresh token dan logout sinkron sering rusak saat dua tab aktif bersamaan. Skenario multi-tab hampir tidak pernah tercakup oleh unit test biasa.
Mengapa unit test saja tidak cukup
Unit test tetap penting, tetapi untuk kasus ini cakupannya terbatas. Anda memerlukan setidaknya satu lapisan test yang menjalankan alur browser nyata.
Apa yang tetap layak diuji dengan unit test
- Reducer atau state machine auth
- Helper untuk mengklasifikasikan status sesi
- Fungsi refresh tunggal agar tidak membuat promise ganda
- Guard komponen untuk tiga status dasar
Apa yang harus naik ke integration test atau E2E
- SSR menghasilkan UI yang sama dengan state client awal
- Halaman privat tidak mem-flash konten sebelum redirect
- Refresh token tidak menyebabkan user logout palsu
- Cookie sesi benar-benar dipakai pada request browser yang relevan
- Redirect halaman privat terjadi sebelum UI sensitif tampil
Strategi test integrasi dan E2E yang relevan
1. Integration test untuk provider dan route shell
Tujuannya memverifikasi bahwa initialAuth dari server benar-benar menjadi state awal client, tanpa harus menunggu efek tambahan untuk render pertama.
2. E2E untuk skenario hydration nyata
Skenario minimum yang sebaiknya ada:
- User login reload halaman: navbar tidak berubah dari guest ke login setelah mount.
- User guest membuka halaman privat: tidak ada flash konten privat sebelum redirect.
- Access token kedaluwarsa tetapi refresh valid: halaman tetap stabil tanpa logout palsu.
- Sesi server hilang: client tidak mempertahankan UI login dari cache lama.
- Multi-tab: logout/refresh di satu tab tidak membuat tab lain masuk state setengah valid.
3. Assertions yang tepat
Jangan hanya memeriksa tujuan akhir seperti “akhirnya user ada di dashboard”. Periksa juga apa yang sempat tampil di tengah. Banyak bug hydration justru ada di transisi singkat itu.
Kesalahan desain yang paling sering memicu bug ini
- Menganggap auth adalah state client biasa, padahal SSR juga ikut menentukan UI.
- Menyatukan keputusan auth, fetch profil, dan redirect dalam satu hook tanpa fase yang jelas.
- Menggunakan
user === nulluntuk mewakili terlalu banyak makna. - Menjalankan proteksi rute hanya di client, padahal server sudah bisa memutuskan lebih awal.
- Mencampur cookie HTTP-only dan storage browser tanpa otoritas yang jelas.
Penutup
Bug hydration auth di Next.js jarang berasal dari satu baris kode yang salah. Biasanya ia lahir dari ketidaksamaan persepsi antara server, client, dan UI tentang siapa user saat render pertama terjadi. Itulah sebabnya unit test bisa lolos, tetapi browser tetap menunjukkan perilaku aneh.
Jika Anda ingin menghilangkan kelas bug ini secara sistematis, mulai dari empat langkah inti: snapshot state server, guard render yang eksplisit, boundary client-only untuk bagian yang memang perlu browser, dan test integrasi/E2E yang memeriksa transisi hydration, bukan hanya hasil akhir. Dengan begitu, auth tidak lagi menjadi serangkaian tebakan setelah mount, tetapi alur yang konsisten sejak HTML pertama dikirim.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!