Kegagalan third-party Search API (seperti kuota Google Custom Search habis, rate limit, atau outage) sering memicu bug hydration mismatch pada aplikasi Server-Side Rendering (SSR). Masalah muncul saat Node.js server merender UI fallback error, namun runtime browser hydration mencoba merender layout sukses berdasarkan state cache lokal (seperti SWR, React Query, atau sessionStorage).

Artikel ini membahas root cause desinkronisasi DOM ini dan solusi arsitekturnya: Adapter Pattern multi-provider di backend SSR, kontrak data serializable, serta isolasi dynamic widget di client.

Root Cause: Mengapa Hydration Mismatch Terjadi Saat Provider Mati?

Hydration mismatch terjadi ketika tree Virtual DOM yang dihasilkan server berbeda dengan Virtual DOM yang dibangun browser pada render pertama. Skenario umumnya:

  1. Server execution: Query search dieksekusi di getServerSideProps atau React Server Components (RSC). Provider utama gagal (HTTP 429 atau 503). Server menangkap exception dan mengembalikan state error atau fallback kosong: { results: [], error: 'PROVIDER_DOWN' }. HTML statis yang dikirim berisi elemen <div class="error-banner">.
  2. Client execution: Browser menerima HTML tersebut. Namun, hook data-fetching di client mendeteksi stale cache dari pencarian sebelumnya atau langsung mengeksekusi revalidasi via Service Worker / HTTP cache browser. Client mencoba me-render <ul class="result-list"> pada node yang sama.
  3. Hydration failure: React/Vue mendeteksi perbedaan struktur tag dan atribut antara server HTML dan client VDOM, memicu hydration warning, membuang SSR markup, dan memicu flash of unstyled/wrong content.

Arsitektur Adapter Pattern untuk Multi-Provider Graceful Degradation

Penanganan kegagalan tidak boleh diserahkan ke client browser. Eksekusi fallback harus selesai di layer server sebelum payload JSON dikirim ke client. Gunakan Adapter Pattern untuk membungkus provider primer dan sekunder ke dalam kontrak yang seragam.

// lib/search/types.ts
export interface SearchItem {
  id: string;
  title: string;
  url: string;
  snippet: string;
}

export interface SearchResponse {
  items: SearchItem[];
  source: 'primary' | 'fallback' | 'empty';
  error?: string;
}

export interface SearchProvider {
  search(query: string): Promise<SearchItem[]>;
}

Implementasikan provider primer (misal: Google Custom Search API) dan fallback internal (misal: PostgreSQL Full-Text Search atau Meilisearch internal):

// lib/search/service.ts
import { SearchProvider, SearchResponse } from './types';

export class SearchService {
  constructor(
    private primary: SearchProvider,
    private fallback: SearchProvider
  ) {}

  async execute(query: string): Promise<SearchResponse> {
    if (!query.trim()) {
      return { items: [], source: 'empty' };
    }

    try {
      // Timeout agresif untuk server provider utama
      const items = await Promise.race([
        this.primary.search(query),
        new Promise<never>((_, reject) => 
          setTimeout(() => reject(new Error('TIMEOUT')), 1500)
        )
      ]);
      return { items, source: 'primary' };
    } catch (err) {
      // Fallback transparan di server
      try {
        const items = await this.fallback.search(query);
        return { items, source: 'fallback' };
      } catch (fallbackErr) {
        // Safe degraded state: tetap serializable
        return { 
          items: [], 
          source: 'empty', 
          error: 'Pencarian sedang tidak tersedia.' 
        };
      }
    }
  }
}

Sinkronisasi State Serializable via Props SSR

Kunci eliminasi mismatch: server props adalah Single Source of Truth untuk first-paint. Client cache dilarang menimpa state sebelum hydration selesai. Di Next.js Pages Router atau App Router, teruskan response utuh sebagai serializable props:

// pages/search.tsx (Next.js Pages Router)
import { GetServerSideProps } from 'next';
import { searchService } from '@/lib/search';
import { SearchResponse } from '@/lib/search/types';

interface PageProps {
  initialSearch: SearchResponse;
  query: string;
}

export const getServerSideProps: GetServerSideProps<PageProps> = async (context) => {
  const query = (context.query.q as string) || '';
  const initialSearch = await searchService.execute(query);

  return {
    props: {
      initialSearch,
      query,
    },
  };
};

export default function SearchPage({ initialSearch, query }: PageProps) {
  return (
    <main>
      <SearchWidget initialQuery={query} />
      <SearchResultContainer initialData={initialSearch} />
    </main>
  );
}

Isolasi Boundary Client-Only untuk Dynamic Widgets

Jika terdapat komponen yang mutlak mengandalkan browser cache (misal: recent history pencarian dari localStorage atau status kuota client-side), pisahkan komponen tersebut dari tree render SSR menggunakan mounted flag atau dynamic import tanpa SSR.

// components/SearchHistory.tsx
import { useState, useEffect } from 'react';

export function SearchHistory() {
  const [history, setHistory] = useState<string[]>([]);
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
    const cached = localStorage.getItem('search_history');
    if (cached) setHistory(JSON.parse(cached));
  }, []);

  // Hindari render tag interaktif selama SSR
  if (!isMounted) {
    return <div style={{ minHeight: '2rem' }} aria-hidden="true" />;
  }

  return (
    <ul>
      {history.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

Di Next.js App Router, gunakan dynamic import dengan opsi SSR dinonaktifkan:

import dynamic from 'next/dynamic';

const SearchHistory = dynamic(() => import('./SearchHistory'), {
  ssr: false,
  loading: () => <div style={{ minHeight: '2rem' }} />,
});

Mitigasi Cumulative Layout Shift (CLS) pada Fallback UI

Saat provider primer gagal dan sistem berganti ke state kosong atau fallback internal, UI rentan mengalami layout shift drastis. Perbedaan tinggi layout antara list hasil pencarian dengan pesan error "Layanan tidak tersedia" sering menghancurkan skor Web Vitals (CLS).

1. Reservasi Ukuran Kontainer Minimum

Tetapkan min-height pada elemen kontainer hasil pencarian agar transisi dari render loading ke fallback tidak menyebabkan lonjakan elemen footer ke atas:

.results-container {
  min-height: 480px;
  display: flex;
  flex-direction: column;
}

2. Skeleton State yang Selaras

Pastikan container skeleton rendering memiliki dimensi yang identik dengan hasil adapter fallback. Gunakan CSS grid atau flexbox yang sama antara state data riil dan state penanda status server.

Checklist Implementasi & Validasi

  • Server Timeout: Batasi timeout Search API eksternal maksimal 1.5 - 2 detik di SSR runtime agar TTFB tidak melonjak.
  • Serializable Props: Jangan kirim raw Error object dari server ke props client. Selalu petakan ke string error code yang dapat diserialisasi JSON.
  • Cache-Control Header: Jangan gunakan Cache-Control: public, s-maxage=... jika response merupakan fallback error, untuk menghindari CDN menyimpan halaman error secara permanen saat outage sementara.
  • Validasi Hydration: Jalankan build pada mode development dan periksa browser console. Pastikan tidak ada pesan "Text content did not match. Server: ... Client: ..." saat provider dimatikan secara sengaja dalam skenario testing.