Skip to main content

React Router: Navigation and Routing

Published: May 24, 2026 Updated: August 29, 2026 Larry Qu 5 min read

React Router turns a single-page React app into a multi-page experience by mapping URL paths to components. Version 6 (the current release) simplified the API significantly: no more Switch, exact, or render props — just nested <Routes> and <Route> elements.

Basic Setup

npm install react-router-dom
// main.jsx
import { BrowserRouter } from 'react-router-dom'
import App from './App'

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
)

// App.jsx
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import Users from './pages/Users'
import UserDetail from './pages/UserDetail'
import NotFound from './pages/NotFound'

export default function App() {
  return (
    <Routes>
      <Route path="/"           element={<Home />} />
      <Route path="/users"      element={<Users />} />
      <Route path="/users/:id"  element={<UserDetail />} />
      <Route path="*"           element={<NotFound />} />
    </Routes>
  )
}

path="*" is the 404 fallback — it matches anything not caught by earlier routes.

Route Parameters and Query Strings

useParams() extracts dynamic segments from the URL. useSearchParams() reads and updates the query string:

import { useParams, useSearchParams } from 'react-router-dom'

// For /users/42
function UserDetail() {
  const { id } = useParams()  // id = "42" (always a string)
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then(r => r.json())
      .then(setUser)
  }, [id])  // re-fetch if id changes

  return user ? <h1>{user.name}</h1> : <p>Loading...</p>
}

// For /search?q=javascript&page=2
function SearchResults() {
  const [searchParams, setSearchParams] = useSearchParams()
  const query = searchParams.get('q') ?? ''
  const page  = Number(searchParams.get('page') ?? 1)

  const goToPage = (n) => setSearchParams({ q: query, page: n })

  return (
    <>
      <p>Showing page {page} for "{query}"</p>
      <button onClick={() => goToPage(page + 1)}>Next page</button>
    </>
  )
}

Nested Routes with Outlet

Nested routes share a layout. The parent component renders an <Outlet /> where child routes appear:

// App.jsx — route tree
<Routes>
  <Route path="/" element={<RootLayout />}>
    <Route index element={<Home />} />        {/* matches exactly "/" */}
    <Route path="dashboard" element={<DashboardLayout />}>
      <Route index element={<Overview />} />
      <Route path="users"    element={<UserList />} />
      <Route path="settings" element={<Settings />} />
    </Route>
  </Route>
</Routes>

// RootLayout.jsx — wraps all routes
function RootLayout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/dashboard">Dashboard</Link>
      </nav>
      <main>
        <Outlet />  {/* child route renders here */}
      </main>
    </div>
  )
}

// DashboardLayout.jsx — wraps dashboard routes only
function DashboardLayout() {
  return (
    <div className="dashboard">
      <aside>
        <Link to="/dashboard">Overview</Link>
        <Link to="/dashboard/users">Users</Link>
        <Link to="/dashboard/settings">Settings</Link>
      </aside>
      <section>
        <Outlet />
      </section>
    </div>
  )
}

Each <Outlet /> renders the matched child route. This eliminates the need to pass layout components as wrappers around every page component.

Protected Routes

A protected route redirects unauthenticated users to login. In React Router v6, redirect by returning <Navigate>:

// components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'

function ProtectedRoute({ children, requiredRole }) {
  const { user, loading } = useAuth()
  const location = useLocation()

  if (loading) return <div>Loading...</div>

  if (!user) {
    // Redirect to login, preserving the current URL so we can come back
    return <Navigate to="/login" state={{ from: location }} replace />
  }

  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />
  }

  return children
}

// App.jsx
<Route path="/dashboard" element={
  <ProtectedRoute>
    <Dashboard />
  </ProtectedRoute>
} />
<Route path="/admin" element={
  <ProtectedRoute requiredRole="admin">
    <AdminPanel />
  </ProtectedRoute>
} />

The state={{ from: location }} saves where the user was trying to go, so after login you can redirect them back:

// LoginPage.jsx
function LoginPage() {
  const navigate = useNavigate()
  const location = useLocation()
  const redirectTo = location.state?.from?.pathname ?? '/dashboard'

  const handleLogin = async (credentials) => {
    await login(credentials)
    navigate(redirectTo, { replace: true })
  }
}

Lazy Loading Routes

Lazy loading splits the bundle so each route’s code is only loaded when navigated to — dramatically reducing initial load time for large apps:

import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const AdminPanel = lazy(() => import('./pages/AdminPanel'))
const Reports    = lazy(() => import('./pages/Reports'))

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/"          element={<Home />} />  {/* always loaded */}
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/admin"     element={<AdminPanel />} />
        <Route path="/reports"   element={<Reports />} />
      </Routes>
    </Suspense>
  )
}

<Suspense> shows the fallback while the lazy component’s chunk is downloading. Place <Suspense> around the entire <Routes> so any route can trigger the fallback.

Programmatic Navigation

useNavigate() lets you navigate in response to events like form submissions, successful logins, or delete confirmations:

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

function CreateUserForm() {
  const navigate = useNavigate()

  const handleSubmit = async (e) => {
    e.preventDefault()
    const user = await createUser(formData)
    navigate(`/users/${user.id}`)           // go to new user's page
  }

  const handleCancel = () => navigate(-1)   // go back in history

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button type="submit">Create</button>
      <button type="button" onClick={handleCancel}>Cancel</button>
    </form>
  )
}

navigate(-1) goes back one step in browser history. navigate('/path', { replace: true }) replaces the current history entry (useful after login so Back doesn’t return to the login page).

<NavLink> is like <Link> but adds an active class (or whatever you configure) when the current URL matches:

<NavLink
  to="/dashboard"
  className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
>
  Dashboard
</NavLink>

// Or with inline styles
<NavLink
  to="/settings"
  style={({ isActive }) => ({
    fontWeight: isActive ? 'bold' : 'normal',
    color: isActive ? '#007bff' : 'inherit',
  })}
>
  Settings
</NavLink>

Summary

  • React Router v6: <Routes> + <Route> replace <Switch>; no exact needed
  • useParams() for URL parameters (always strings), useSearchParams() for query strings
  • Nest routes under a layout component that renders <Outlet /> where children appear
  • Protected routes: check auth in a wrapper component and return <Navigate> to redirect
  • Lazy load heavy routes with lazy() + <Suspense> to reduce initial bundle size
  • useNavigate() for programmatic navigation after events; navigate(-1) for browser back

Resources

Comments

👍 Was this article helpful?