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. 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 performance, and it introduces a layout shift when the nav pops in after
hydration.
The Right Fix
The actual problem is narrow: I're 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. 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, 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. 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. I 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. I find the narrowest possible and acceptable neutral state
that both environments can agree on. Then, I upgrade to the real value after
hydration. React's useSyncExternalStore makes that an easy three-line hook.
