Skip to content

SPA Mode + <NavLink to="/" end> rendered in root.tsx Layout component will always start as "active"Β #13010

@tjallingt

Description

@tjallingt

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

  1. run react-router build in the terminal
  2. serve build/client with a webserver
  1. open localhost:8080, NavLink for "home" should be active (orange background)
  2. click on NavLink for "other", this navigates and makes it active
  3. refresh the page now the NavLink for "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/other directly

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 NavLinks should rerender to show the active class

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions