SPA Mode + `<NavLink to="/" end>` rendered in `root.tsx` Layout component will always start as "active"
I'm using React Router as a...
framework
Reproduction
https://stackblitz.com/edit/github-7pxkksy3?file=app%2Froot.tsx
Project setup
root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
NavLink,
} from 'react-router';
import './app.css';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<nav>
<ul>
<li>
<NavLink to="/" end>
Home
</NavLink>
</li>
<li>
<NavLink to="/other">Other</NavLink>
</li>
</ul>
</nav>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('other', 'routes/other.tsx'),
] satisfies RouteConfig;
app.css
nav a.active {
background-color: #e76829;
}
routes/home.tsx
export default function Home() {
return (
<div className="text-center p-4">
<h1 className="text-2xl">Hello, Home</h1>
</div>
);
}
routes/other.tsx
export default function Other() {
return (
<div className="text-center p-4">
<h1 className="text-2xl">Hello, Other</h1>
</div>
);
}
react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;
Steps to reproduce
- run
react-router buildin the terminal - serve
build/clientwith a webserver
cd ./build/clientminiserve --spa --index index.html(using https://github.com/svenstaro/miniserve as an example but can use whatever server for SPA)
- open
localhost:8080,NavLinkfor "home" should be active (orange background) - click on
NavLinkfor "other", this navigates and makes it active - refresh the page now the
NavLinkfor "home" is active again even though we're looking at the "other" page
- this also happens when just skipping step 3 and 4 and navigating to
localhost:8080/otherdirectly
System Info
System:
OS: Windows 11 10.0.22631
CPU: (8) x64 11th Gen Intel(R) Core(TM) i5-1145G7 @ 2.60GHz
Memory: 1.17 GB / 15.39 GB
Binaries:
Node: 22.13.1 - ~\scoop\apps\nvm\current\nodejs\nodejs\node.EXE
npm: 10.9.2 - ~\scoop\apps\nvm\current\nodejs\nodejs\npm.CMD
Browsers:
Edge: Chromium (131.0.2903.146)
Internet Explorer: 11.0.22621.3527
npmPackages:
@react-router/dev: ^7.1.3 => 7.1.5
@react-router/node: ^7.1.3 => 7.1.5
react-router: ^7.1.3 => 7.1.5
vite: ^6.0.7 => 6.1.0
Used Package Manager
npm
Expected Behavior
When serving the build/client directory with a server and directly visiting localhost:8080/other the NavLink for "other" should be active.
Actual Behavior
When directly visiting localhost:8080/other the NavLink for "home" is active.
Note that if you have multiple NavLink "home" stays active until you visit the home route and navigate away again. Navigating to any other route while "home" is active will show two active NavLinks.
Workaround
Moving the whole <nav> into the routes/home.tsx and routes/other.tsx directly seems to fix the issue, when reloading the page the correct NavLink becomes active.
Potential cause
When looking at build/client/index.html we can see that the NavLinks have been pre-rendered (as described in the docs https://reactrouter.com/how-to/spa#important-note) and the "home" NavLink has been pre-rendered with class="active".
Somehow after hydration this active class should switch to the correct NavLink but it doesn't. Interestingly there are no hydration warnings from React in the console.
Potential solutions
This could very well be me "misusing" NavLinks (that they are not supposed to be rendered in <Layout />), in which case I hope I can be pointed to the right docs or that I can help update the documentation to make this behaviour clearer.
Otherwise I see two strategies:
NavLinks should pre-render without their active class- After hydration the
NavLinksshould rerender to show the active class
As you commented, this is essentially a "misuse" of NavLink within the root module. And you are also correct that we have poor documentation here. We have #13000 open to track adding that into our docs.
I'm not sure what a good fix is here, as the root module is being rendered down to plain HTML and will be static upon first load. We choose the root URL to render against, so that is why it is active despite the different URL in the browser. Maybe a warning of some sort could be added? I'm not sure how possible that might be.
As mentioned this would be expected on first load, but I'm unsure why it's not flipping over on hydration. NavLink runs again on hydration and properly detects that / is no longer "active" but it doesn't seem like that render flushes to the DOM...
Duplicating the layout <html>...</html> shell in your default export and a HydrateFallback fixes the issue, as does manually wrapping a layout and skipping the built in Layout component usage:
export default function App() {
return <MyLayout><Outlet /></MyLayout>;
}
export function HydrateFallback() {
return <MyLayout><p>Loading...</p></MyLayout>;
}
So that tells me it has something to do with the way Layout is wrapped around the root UI components internally. I don't have time to dig in right now but will try to take a look at some point.
Duplicating the layout ... shell in your default export and a HydrateFallback fixes the issue
Yea after making this issue I added a HydrateFallback since the console.log encouraged me to do so. After moving my <NavBar /> component from the root.tsx Layout to every page and the root.tsx HydrateFallback I get behaviour that is, almost, what I want:
The page loads with "home" active and then rerenders making "other" active.
This way I get the benefits from prerendering since my initial page contains the nav bar (unfortunately with the "home" NavLink active but ok) and after hydration the page updates to show the correct NavLink as active.
I think it would make sense if during prerendering the NavLinks would not render className="active". Conceptually you're just prerendering the shell/root, which is not really at any particular URL. Ofcourse this is different when doing SSR where the URL is known and the NavLink should render as active.
is it even expected for react router rerender entire App export considering it changes the matches key on every link/navbarlink changes?
App component rendered with props: {
params: {},
loaderData: undefined,
actionData: undefined,
matches: [
{
id: 'root',
pathname: '/',
params: {},
data: undefined,
handle: undefined
},
{
id: 'routes/about',
pathname: '/about',
params: {},
data: undefined,
handle: undefined
}
]
}
export const MyNavbar2 = React.memo(() => {
console.log("MyNavbar rendered");
return <nav>.................................</nav>;
});
export default function App(props) {
console.log("App component rendered with props:", props);
return (
<>
<MyNavbar2 />
<Link className="navbar-item" to="/">
Home
</Link>
<Link className="navbar-item" to="/about">
About
</Link>
<Link className="navbar-item" to="/login">
Auth
</Link>
<Link className="navbar-item" to="/" />
<Outlet />
</>
);
}
export function Layout(args) {
console.log("Layout component rendered with args:", args);
const { children } = args;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
</head>
<body>
<MyNavbar2 />
{children}
<Scripts />
</body>
</html>
);
}
both mynavbar2 get rerendered on navbarlink clicks, devtools show that they are memoized