Protected routes
Some parts of an app should only be visible to a signed-in user: a dashboard, an account page, anything that fetches personal data. React Router has no dedicated "protected route" feature, and it doesn't need one. The pieces from routing and nested routes combine into the pattern: a layout route that checks whether the user is logged in and renders either its children or a redirect to the login page.
One thing to be clear about before any code. Guarding routes on the client is a user experience feature. It keeps signed-out visitors off pages that would fetch their data and show broken, empty screens. It is never security, because everything in a client bundle is inspectable and bypassable.
Real protection happens on the server, which must refuse to hand out data to requests that aren't authenticated. Authentication covers how that enforcement works; this chapter is about the client half.
An auth-required layout route
A layout route is a parent route whose element renders shared UI plus an Outlet for its children. A pathless layout route adds no URL segment at all; it exists purely to wrap. That makes it the perfect place for a login check: wrap every route you want to protect in a pathless layout route whose only job is deciding whether the Outlet renders.
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="login" element={<Login />} />
<Route element={<AuthRequired />}>
<Route path="host" element={<Dashboard />} />
<Route path="host/vans" element={<HostVans />} />
<Route path="host/vans/:id" element={<HostVanDetail />} />
</Route>
</Route>Everything nested inside <Route element={<AuthRequired />}> is now behind the guard, including routes with params like host/vans/:id. Protecting a new page later is one line: move its route inside the wrapper.
The guard itself has two branches. If the user is logged in, render the Outlet so the matched child appears. If they aren't, render something that sends them to the login page instead. Rendering nothing is the whole trick: if a protected component never renders, any data fetching inside it never kicks off.
import { Outlet, Navigate } from 'react-router-dom'
export default function AuthRequired() {
const authenticated = false // stand-in for a real session check
if (!authenticated) {
return <Navigate to="/login" />
}
return <Outlet />
}The course fakes the check with a boolean, and later a value in localStorage, to keep the focus on routing. A real check reads session state, often delivered through context; again, Authentication covers where that state actually comes from.
Version note
The examples here import from react-router-dom, which works in both v6 and v7; Routing covers what v7 changed about the packages. The course teaches v6 throughout this section.
The Navigate component
Link renders an anchor and waits to be clicked. Navigate skips the waiting: as soon as it renders, the router moves the user to its to path. That makes it the redirect tool for render logic.
Your component decides, mid-render, that the user shouldn't be here, and returns <Navigate to="/login" /> instead of any UI. There is also a useNavigate hook that returns a function for redirecting from event handlers, which is how the login form itself sends the user onward after a successful submit.
Landing on a bare login page with no explanation is confusing, so the guard should say why the user ended up there. Navigate takes the same state prop Link does, so a redirect can hand the destination a reason.
if (!authenticated) {
return (
<Navigate
to="/login"
state={{ message: 'You must log in first' }}
/>
)
}import { useLocation } from 'react-router-dom'
function Login() {
const location = useLocation()
return (
<>
{location.state?.message && <h3>{location.state.message}</h3>}
<h1>Sign in to your account</h1>
{/* form... */}
</>
)
}The optional chaining matters, because someone who clicks straight to the login page never passed through the guard and finds location.state set to null.
The history stack and replace
The pattern so far has a bug you can feel. Visit a protected page while logged out, get redirected, log in, then press the browser's Back button. Instead of the page you were on before all this, you land on the login page again, complete with its "you must log in first" message.
The browser keeps a history stack: every navigation pushes a new entry, and Back pops to the previous one. The redirect pushed /login onto the stack right after the protected page, and the login form's own navigation pushed the protected page on top of that. Back walks you straight into the leftovers.
The fix is to make redirects replace the current entry instead of pushing a new one. On the Navigate component that's the replace prop; on the useNavigate function it's an option.
// in AuthRequired: the redirect swaps itself in for the blocked page
return <Navigate to="/login" state={{ message: 'You must log in first' }} replace />
// in Login, after a successful submit
navigate('/host', { replace: true })With both in place, the login detour never survives in history. Back from the protected page now goes to wherever the user was before, and the login page can't be revisited by accident. A good rule of thumb: any navigation the user didn't ask for, which is what a redirect is, should replace.
Back to where they were headed
One rough edge remains. The login form hard-codes navigate('/host', ...), so a user who was trying to reach /host/vans/2 gets dumped on /host after signing in. The guard knows the blocked URL, because useLocation inside AuthRequired describes the page the user was trying to render. Pass it along in the same navigation state.
export default function AuthRequired() {
const location = useLocation()
const authenticated = false
if (!authenticated) {
return (
<Navigate
to="/login"
state={{
message: 'You must log in first',
from: location.pathname + location.search + location.hash,
}}
replace
/>
)
}
return <Outlet />
}pathname alone is not the whole address: a user blocked at /host/vans?type=luxury was headed for the filtered list, and dropping the query string strips the filter. Adding search and hash reproduces every part of the URL the router tracks. The login page reads the value back, with a fallback for people who came to the login page directly and so have no state.
function Login() {
const location = useLocation()
const navigate = useNavigate()
const from = location.state?.from || '/host'
function handleLogin() {
// ...on success:
navigate(from, { replace: true })
}
// ...
}Now a shared link to any protected page survives the login detour: blocked, redirected, signed in, and delivered to the exact URL they wanted.
Outlet and the pages show normally. If they aren't, it renders Navigate, which whisks them to the login page the moment it appears. Adding replace keeps the redirect out of the Back button's memory, and passing along where they were headed lets the login page send them right back there afterward.
Remember that this is friendliness for the user; the server still has to protect the data itself.
Next up: How React renders, the mental model behind every performance decision that follows.

