Protected Routes

Guarding a page is the same redirect as the last subsection, decided by a condition instead of by the URL. The idiom is a layout route: a pathless <Route> whose element checks the condition and renders either <Outlet /> — letting the matched child through — or a <Navigate> to the login page. Everything nested under it is protected by construction, so adding a route to the group is a one-line change. Two details make the experience right: state={{ from: location }} tells the login page where the user was headed, and replace on both navigations keeps neither the guarded URL nor the login page in history for Back to find.

A layout route that guards its children, with a round trip through loginHTML
<style>body{font:15px/1.6 system-ui;margin:12px}a{margin-right:12px}
.box{border:1px solid #90a4ae;padding:6px 10px;border-radius:6px;margin-top:8px}</style>
<div id="root"></div>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",
"react-router":"https://esm.sh/react-router@8.4.0?external=react"}}</script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
<script type="text/babel" data-type="module">
import React, { createContext, useContext, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Routes, Route, Link, Outlet, Navigate,
         useNavigate, useLocation } from 'react-router';
const Auth = createContext(null);
function RequireAuth() {                          // a layout route, not a wrapper
  const { user } = useContext(Auth);
  const location = useLocation();
  if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
  return <Outlet />;                              // matched child route renders here
}
function Login() {
  const { signIn } = useContext(Auth), navigate = useNavigate();
  const from = useLocation().state?.from?.pathname || '/';   // where we were headed
  return <div className="box">You must log in to see {from}{' '}
    <button onClick={() => { signIn('ada'); navigate(from, { replace: true }); }}>
      Log in</button></div>;
}
function App() {
  const [user, setUser] = useState(null);
  return <Auth value={{ user, signIn: setUser }}>
    <MemoryRouter>
      <nav><Link to="/">Public</Link><Link to="/reports">Reports</Link>
        user: <b>{user || 'anonymous'}</b></nav>
      <Routes>
        <Route path="/" element={<h3>Public page</h3>} />
        <Route path="/login" element={<Login />} />
        <Route element={<RequireAuth />}>
          <Route path="/reports" element={<h3>Reports (protected)</h3>} />
        </Route>
      </Routes>
    </MemoryRouter>
  </Auth>;
}
createRoot(document.getElementById('root')).render(<App />);
setTimeout(() => document.querySelectorAll('nav a')[1].click(), 200);   // Reports
setTimeout(() => document.querySelector('button').click(), 400);        // Log in
</script>
Browser output of Listing 2.144
Browser output of 144

The screenshot follows both scripted clicks: Reports while anonymous bounced to the login page, and logging in returned to /reports. Replace useState with your real session — a token in memory, a useSyncExternalStore over an auth SDK (useSyncExternalStore) — and the shape holds. While that session is still loading, return a placeholder from the guard rather than <Navigate>, or every reload flashes the login page.