Skip to content

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.

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

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

jsx
if (!authenticated) {
  return (
    <Navigate
      to="/login"
      state={{ message: 'You must log in first' }}
    />
  )
}
jsx
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.

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

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

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

The const authenticated = false stand-in hides the first bug this pattern runs into in a real app. Session state resolves asynchronously: on the first render the app has asked the auth provider whether a session exists and hasn't heard back yet. A two-way guard reads that silence as "signed out" and redirects a user who is signed in, so a refresh on a protected page sends them to the login screen for a moment before the app corrects itself. Model three states instead, which is the same shape Authentication builds on the provider side.

jsx
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthProvider'

export default function AuthRequired() {
  const { session } = useAuth() // undefined while resolving, null when signed out
  const location = useLocation()

  if (session === undefined) {
    return <p>Checking your session...</p>
  }
  if (session === null) {
    return (
      <Navigate
        to="/login"
        state={{
          message: 'You must log in first',
          from: location.pathname + location.search + location.hash,
        }}
        replace
      />
    )
  }
  return <Outlet />
}

useAuth is a custom hook over a context provider, the pattern from context: the provider owns the session state and every component reads the same answer. The three values are the whole point. undefined means the check is still in flight, null means it came back empty, and a session object means someone is signed in.

The loading branch is the one people leave out, and it is the difference between a guard that survives a refresh and one that bounces signed-in users. Keep that branch light, since it renders on the first paint of every protected page, and give it enough presence that the screen doesn't look broken while the check runs.

Navigate is a declarative redirect: rendering it is the instruction. Under the hood it calls the router's navigate function from an effect after the render commits, because navigating is a side effect and a component cannot cause one while it is rendering. Returning it early also short-circuits the whole subtree, and that is the real payoff of guarding at the layout level: components that never mount never run their effects, so their fetches never fire. The guard sits at one choke point instead of being sprinkled through every protected component as a per-page check.

Navigation state rides on the browser's history entry, with all the consequences route params works through. replace maps directly to history.replaceState semantics: the entry is overwritten, so the redirect leaves no trace to walk Back into.

A guard written as a component has to render before it can decide anything, so an unresolved session buys a loading state at best. React Router's data APIs move the decision earlier: a loader runs before the route's element renders, and throwing a redirect from it means the protected component is never created in the first place.

jsx
import { redirect } from 'react-router-dom'

async function hostLoader({ request }) {
  const session = await getSession()
  if (!session) {
    const url = new URL(request.url)
    const from = url.pathname + url.search + url.hash
    throw redirect(`/login?from=${encodeURIComponent(from)}`)
  }
  return null
}

That version carries the origin in the query string, and the move brings a consequence to catch before it ships. History state is written by your own guard; a query string is typed by whoever sends the link.

Validate the from value before navigating

/login?from=https://phish.example/host is a valid URL anyone can post, and a login handler that reads from and hands it to navigate will deliver the user off-site the instant they authenticate, having watched them type a password on your real login page. Accept the value only when it starts with a single /, which rejects both absolute URLs and the protocol-relative //host form that browsers treat as off-site.

jsx
const raw = searchParams.get('from') || ''
const from = /^\/(?!\/)/.test(raw) ? raw : '/host'
JunoOne gatekeeper for the whole branch Instead of checking for a login inside every private page, you wrap all of those routes in one gatekeeper component. If the user is signed in, it renders 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.

JunoOne gatekeeper for the whole branch Wrap protected routes in a pathless layout route whose element reads the session from a useAuth hook and branches three ways: a loading line while the session is undefined, Navigate to="/login" when it is null, and Outlet once a session object arrives. Skipping that first branch is what bounces signed-in users to the login page after a refresh.

Give the redirect a state object carrying a message plus location.pathname + location.search + location.hash as from, mark it replace, and in the login handler call navigate(from, { replace: true }) with a sensible fallback. Keep the server enforcing access on every request regardless.

JunoOne gatekeeper for the whole branch Guarding at the layout level short-circuits the subtree before it mounts, so protected components never render and their fetches never start. Navigation state lives on the history entry itself, and replace is history.replaceState in router clothing: redirects should always replace so the detour leaves no entry behind.

A component guard still has to render once before it can decide, so in data-router setups the check moves into a loader that throws redirect before the element exists. Once the origin URL travels in a query string it is attacker-supplied, so validate it against a leading single slash before navigating to it.

None of it is enforcement; the server owns that.

Next up: How React renders, the mental model behind every performance decision that follows.