Skip to main content

Fixing Hydration Mismatches Without Suppressing Them

Apr 6, 20262 min readReact, Next.js, SSR, Accessibility, Debug

The Mismatch

Our mobile navigation uses aria-current="page" to mark the active link. The value comes from usePathname(), which returns the current URL on the client, but during server-side rendering, every page's nav gets the same initial pathname. The result: the server marks "Home" as the active link, the client marks "Projects" as the active link, and React throws a hydration error.

The error itself is harmless. React recovers by re-rendering on the client. But it's noisy in dev, breaks strict mode, and indicates a real disagreement between server and client output. Suppressing it with suppressHydrationWarning just hides the symptom.

The Wrong Fix

The first instinct is to move the entire nav into a client-only component with dynamic(() => import('./Nav'), { ssr: false }). That works, but it means the navigation renders nothing on the server — bad for SEO, bad for first paint and performace, and it introduces a layout shift when the nav pops in after hydration.

The Right Fix

The actual problem is narrow: we're rendering a value that differs between server and client. We don't need to skip SSR entirely — we just need to render a neutral value during SSR and switch to the real one after hydration.

useSyncExternalStore gives us exactly that. Its third argument, getServerSnapshot, runs only during SSR:

const subscribe = () => () => {};
const getSnapshot = () => true;
const getServerSnapshot = () => false;
 
function useHydrated() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

The hook returns false on the server and true on the client after hydration. No external store, no state, no effect — just React's built-in mechanism for declaring "this value differs between environments."

Applying It

In the nav component, we defer the active link state until after hydration:

export default function Nav() {
  const pathname = usePathname();
  const hydrated = useHydrated();
  const activePathname = hydrated ? pathname : '';
 
  return (
    <>
      <header className='hidden md:block sticky top-0 ...'>
        <TabletUpNav pathname={activePathname} />
      </header>
      <MobileNav pathname={activePathname} />
    </>
  );
}

During SSR, activePathname is '', so no link gets aria-current="page". After hydration, it switches to the real pathname, and the correct link lights up. Server and client agree on the initial render. No mismatch.

The Bonus: Skeleton Loading

Since the desktop nav uses dynamic(() => import('./TabletUpNav'), { ssr: false }) for code splitting, it already has a loading state. We replaced the generic Spinner with a NavSkeleton that mirrors the actual nav layout: a logo placeholder, four link-shaped rectangles, and three right-side elements.

function NavSkeleton() {
  return (
    <div className='hidden md:flex max-w-4xl mx-auto px-6 lg:px-0 h-14 items-center gap-1'>
      <Skeleton variant='rectangular' width={32} height={32} />
      <div className='flex items-center gap-1 ml-6'>
        <Skeleton variant='rectangular' width={68} height={32} />
        <Skeleton variant='rectangular' width={68} height={32} />
        <Skeleton variant='rectangular' width={82} height={32} />
        <Skeleton variant='rectangular' width={60} height={32} />
      </div>
    </div>
  );
}

The widths match the actual rendered links. No layout shift, no spinner that gives no spatial information about what's loading.

The Takeaway

When server and client disagree on a value, the fix isn't to skip SSR or suppress the warning. We find the narrowest possible and acceptable neutral state that both environments can agree on. Then, we upgrade to the real value after hydration. React's useSyncExternalStore makes that an easy three-line hook.