March 26, 20266 min read

React Router v6: The Mental Model and Practical Patterns

How React Router v6 works -- BrowserRouter, nested routes, route params, navigation, protected routes, lazy loading, and the URL-as-state mental model.

react react-router routing javascript frontend
Ad 336x280

Here's the mental model that makes React Router click: the URL is state. When the URL changes, React Router figures out which components should render. Routes are just a mapping from URL patterns to components. That's it. Everything else -- params, navigation, nesting -- follows from that idea.

Basic Setup

import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/:id" element={<UserProfile />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}

BrowserRouter provides the routing context. Routes picks the best match. Route maps a path to a component. The * route is your 404 page -- it matches anything that didn't match above.

Don't use tags for internal navigation. They cause a full page reload. Use Link or NavLink:

import { Link, NavLink } from 'react-router-dom';

function Nav() {
return (
<nav>
<Link to="/">Home</Link>
<NavLink
to="/about"
className={({ isActive }) => isActive ? 'nav-active' : ''}
>
About
</NavLink>
</nav>
);
}

NavLink is Link with awareness of whether it matches the current URL. Useful for highlighting the active nav item.

For programmatic navigation (after a form submit, after login, etc.):

import { useNavigate } from 'react-router-dom';

function LoginForm() {
const navigate = useNavigate();

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await login(credentials);
navigate('/dashboard');
// or navigate(-1) to go back
// or navigate('/dashboard', { replace: true }) to replace history entry
}

return <form onSubmit={handleSubmit}>...</form>;
}

Route Parameters

Dynamic segments in the path become params:

// Route: <Route path="/users/:id" element={<UserProfile />} />

import { useParams } from 'react-router-dom';

function UserProfile() {
const { id } = useParams<{ id: string }>();
// id is always a string -- parse it if you need a number
// fetch user with this id...

return <div>User {id}</div>;
}

Params are always strings. If your ID is a number, you'll need parseInt or Number(). This trips people up when comparing with === against numeric IDs from an API.

Query Parameters

For search filters, pagination, and other URL state that isn't part of the path:

import { useSearchParams } from 'react-router-dom';

function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const category = searchParams.get('category') || 'all';

function nextPage() {
setSearchParams(prev => {
prev.set('page', String(page + 1));
return prev;
});
}

// URL looks like: /products?page=2&category=electronics
return (
<div>
<p>Page {page}, Category: {category}</p>
<button onClick={nextPage}>Next Page</button>
</div>
);
}

This is URL as state in action. The page number lives in the URL, so users can bookmark it, share it, or hit the back button and it just works. Way better than hiding pagination in component state.

Nested Routes and Layout Routes

This is where v6 really shines. You can nest routes to share layout:

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="dashboard" element={<Dashboard />}>
            <Route index element={<DashboardHome />} />
            <Route path="settings" element={<Settings />} />
            <Route path="analytics" element={<Analytics />} />
          </Route>
        </Route>
        <Route path="/login" element={<Login />} />
      </Routes>
    </BrowserRouter>
  );
}

The Layout component renders the shared chrome (header, sidebar, footer) and an Outlet where child routes render:

import { Outlet } from 'react-router-dom';

function Layout() {
return (
<div>
<Header />
<main>
<Outlet />
</main>
<Footer />
</div>
);
}

/dashboard/settings renders Layout > Dashboard > Settings. The index route is what renders when you hit exactly /dashboard with no sub-path. Notice that /login is outside the layout -- no header or footer on the login page.

Protected Routes

The pattern for auth-gated routes:

import { Navigate, useLocation } from 'react-router-dom';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user } = useAuth(); // your auth hook
const location = useLocation();

if (!user) {
// Redirect to login, but remember where they were going
return <Navigate to="/login" state={{ from: location }} replace />;
}

return <>{children}</>;
}

Use it as a wrapper:

<Route
  path="dashboard"
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  }
>
  <Route index element={<DashboardHome />} />
  <Route path="settings" element={<Settings />} />
</Route>

Or as a layout route (cleaner for many protected pages):

function ProtectedLayout() {
  const { user } = useAuth();
  const location = useLocation();

if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <Outlet />;
}

// In your routes:
<Route element={<ProtectedLayout />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="profile" element={<Profile />} />
<Route path="settings" element={<Settings />} />
</Route>

After login, you can redirect back:

function Login() {
  const location = useLocation();
  const navigate = useNavigate();
  const from = (location.state as any)?.from?.pathname || '/dashboard';

async function handleLogin() {
await login(credentials);
navigate(from, { replace: true });
}
// ...
}

Lazy Loading Routes

For larger apps, you don't want to load every page's JavaScript upfront:

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

React.lazy code-splits each page into its own chunk. The browser only downloads the JavaScript for a page when the user navigates to it. Suspense shows a fallback while the chunk loads. For a dashboard that most users visit once a week, this matters a lot.

Common Mistakes

Forgetting the * catch-all: Without it, unmatched URLs show a blank page instead of a 404. Putting Outlet in the wrong place: If your nested routes aren't rendering, you probably forgot in the parent component. Using
instead of : Full page reload, all React state lost. Use Link for internal navigation, only for external URLs. Hardcoding paths everywhere: If your route structure changes, you're updating strings in 30 files. Consider a route constants file or a helper function.

Routing is one of those things that's best learned by building. Try implementing nested layouts and protected routes yourself on CodeUp -- the interactive environment makes it easy to experiment without any setup overhead.

Ad 728x90