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.
