I have a hydration timing issue in Next.js 15.5.4 where navigation active states work perfectly in development, but disappear during production hydration, despite usePathname()
returning correct values throughout the process.
Specific Problem Statement
Navigation buttons briefly show correct active styling immediately after page load, then revert to default styling within 200-300ms. This behavior occurs consistently in production builds but never in development mode.
Reproduction Steps
-
Build application: npm run build
-
Start production server: node .next/standalone/server.js
-
Navigate to http://localhost:3000/birre
-
Observe navigation component behavior during page load
Expected vs Actual Behavior
-
Expected: Button for “/birre” route shows active styling (dark background, white text) and persists
-
Actual: Button briefly shows active styling, then reverts to default styling (white background, dark text)
-
Development Mode: Active styling persists correctly throughout the page lifecycle
Console Output During Issue
Initial page load to /birre
shows correct pathname detection:
[Navigation] usePathname result: "/birre"
[Navigation] Found matching category: {id: "birre", label: "Birre", href: "/birre"}
[Navigation] Applying active class to category: Birre
[Hydration] React hydration warning: Text content did not match. Server: "" Client: "active"
Browser DevTools Elements tab shows this DOM class change sequence:
-
Initial render: <a class="categoryButton">
(no active class)
-
~200ms later: <a class="categoryButton active">
(active class appears)
-
~100ms later: <a class="categoryButton">
(active class disappears)
Component Implementation
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "@/styles/HorizontalHeader.module.css";
const PRODUCT_CATEGORIES = [
{ id: "birre", label: "Birre", href: "/birre" },
{ id: "vini", label: "Vini", href: "/vini" },
{ id: "condimenti", label: "Condimenti", href: "/condimenti" },
// ... 15 more categories
];
export default function HorizontalHeader() {
const pathname = usePathname();
return (
<header className={styles.horizontalHeader}>
<nav className={styles.nav}>
<div className={styles.navContainer}>
{PRODUCT_CATEGORIES.map((category) => (
<Link
key={category.id}
href={category.href}
className={`${styles.categoryButton} ${
pathname === category.href ? styles.active : ""
}`}
>
<span className={styles.categoryText}>{category.label}</span>
</Link>
))}
</div>
</nav>
</header>
);
}
CSS Module Styles
.categoryButton {
display: block;
text-align: center;
background: white;
border: 1.5px solid #b6c0ba;
padding: 0.5rem 1rem;
transition: all 0.3s ease;
cursor: pointer;
white-space: nowrap;
}
.categoryText {
font-size: 0.85rem;
font-weight: 500;
color: #b6c0ba;
text-transform: uppercase;
}
.categoryButton.active {
background: #b6c0ba;
}
.categoryButton.active .categoryText {
color: white;
}
Environment Details
-
Next.js: 15.5.4 with output: "standalone"
-
React: 19.1.0
-
Node.js: 22 LTS
-
Typescript ^5
-
Build command: npm run build --turbopack
-
Production server: node .next/standalone/server.js
-
Deployment: Ubuntu VPS with PM2 process manager + Nginx reverse proxy
Detailed Investigation
Environment Comparison Testing
-
Development (npm run dev
): Active states persist correctly throughout navigation
-
Production local (node .next/standalone/server.js
): Active states disappear after ~300ms
-
Production deployed (Ubuntu VPS): Identical behavior to local production
DOM Investigation
Using Chrome DevTools Elements panel, I observed this sequence on page load to /birre
:
-
Initial HTML (server-rendered): All navigation buttons have only categoryButton
class
-
First client paint: Browser displays server HTML with no active styling
-
Hydration begins: JavaScript executes, usePathname()
returns “/birre”
-
Active class applied: Target button gains active
class, styling appears correctly
-
Hydration completion: React removes active
class, reverting to server state
Console Debugging Results
Added logging to component to track hydration behavior:
useEffect(() => {
console.log('Component mounted, pathname:', pathname);
console.log('Active category should be:',
PRODUCT_CATEGORIES.find(cat => cat.href === pathname));
}, [pathname]);
Output confirms component logic works correctly:
Component mounted, pathname: /birre
Active category should be: {id: "birre", label: "Birre", href: "/birre"}
Network Tab Analysis
-
No failed resource loads or JavaScript errors
-
All Next.js chunks load successfully
-
CSS modules load correctly with expected class names
React DevTools Profiler
Shows component re-renders during hydration phase, suggesting React is reconciling server and client states and choosing server state.
Configuration Details
next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
package.json scripts:
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start"
}
}
Related Research
I have investigated several potential causes:
-
SSR/Hydration Mismatch: Server renders neutral state, client applies active state, React reconciliation reverts to server state
-
usePathname Timing: Hook may not be available during initial server render, creating server/client content mismatch
-
CSS Module Loading: Verified that CSS classes load correctly and active styles work when manually applied in DevTools
-
Standalone Build Differences: Issue occurs with standalone builds but not standard development server
Attempted Solutions
-
NoSSR wrapper: Prevented server rendering but created layout shift
-
useState hydration flag: Caused development environment chunk loading errors with Turbopack
-
CSS-only solutions: Verified CSS module class names are consistent between server and client renders
-
Manual DOM manipulation: Works but defeats purpose of React declarative approach
Question
How can I prevent React hydration from overriding client-side active states when using usePathname()
in production builds? Is there a recommended pattern for handling route-based styling that survives the hydration process in Next.js standalone deployments?
The core issue appears to be that the server cannot know which route is active during SSR, creating a mismatch that React resolves by favoring the server state. I need a solution that works reliably with standalone builds for VPS deployment.