Skip to main content

Fixing Hydration Mismatches Without Suppressing Them

Two puzzle pieces fitting together on a clean surface
Apr 6, 20262 min readReact, Next.js, SSR, Accessibility

The Mismatch

The 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. So 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, since React recovers by re-rendering on the client. But it's noisy in dev, it breaks strict mode, and it points at a real disagreement between the 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, which is bad for SEO, bad for first paint and performance, and it adds a layout shift when the nav pops in after hydration.

The Right Fix

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

useSyncExternalStore gives me 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, with no external store, no state, and no effect; it's just React's built-in way of declaring "this value differs between environments."

Applying It

In the nav component, I 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, so the server and client agree on the initial render and there's no mismatch.

The Bonus: Skeleton Loading

Since the desktop nav uses dynamic(() => import('./TabletUpNav'), { ssr: false }) for code splitting, it already had a loading state, so I swapped the generic Spinner for 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, so there's no layout shift and no spinner that tells you nothing about what's loading.

The Takeaway

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