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.
<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>
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.