The /taller route has been on CicloUrbano's map since 06-02, and today anyone can open it: typing the address into the browser's bar is enough. Only Marc Solé, the operator usr-02, should see it — not Ana Ribera, and not a visitor with no session. In this lesson you'll build that access control: a fictitious sign-in at /acceso, a guard component that redirects whoever has no session and knows how to send them back to their original destination afterwards, a protected layout route that groups several screens under one guard, role-based authorization with a "forbidden" screen distinct from "not found", handling the in-between state while the session is being checked, and persistence with useLocalStorage. But first, the warning that governs the whole lesson and that you should keep in mind on every line of code you write.
⚠️ Essential warning: this is not security
Everything you do on the client is user experience, nothing more. Real authorization is ALWAYS checked on the server.
This isn't a recommendation or a best practice: it's a fact about how the web works. Your application's code downloads in full to the user's browser, and there they are the absolute owner. They can:
- Open the developer tools and change any variable in memory, including
isOperator. - Set a breakpoint in your guard and step past it.
- Edit
localStorageby hand to give themselves whatever role they like. - Read all the downloaded JavaScript, including "protected" screens, without ever needing to reach them.
- Call your API directly with
curl, bypassing the interface entirely.
What a route guard actually achieves:
| What it DOES do | What it does NOT do |
|---|---|
| Keeps a legitimate user from seeing a screen that isn't meant for them | Stop someone determined from seeing it |
Redirects whoever hasn't signed in to /acceso |
Protect the data that screen displays |
| Shows an interface that's coherent with the role | Replace the server-side check |
| Avoids errors from data that never arrives | Hide the screen's code |
The practical consequence: every request the protected screen makes to the API must carry its credentials, and the server must check, on every single one, whether that user is allowed to perform that operation. If the server hands back the incident list to anyone who asks for it, your ProtectedRoute is decoration. We'll come back to this at the close of the lesson, because it's the one thing here that admits no nuance.
Contents
- CicloUrbano's session model
- The sign-in screen
- Pattern 1: the
ProtectedRouteguard component - Returning to the original destination after signing in
- Pattern 2: the protected pathless route
- Role-based authorization:
RequireRole - 403 and 404: why they're different screens
- The in-between state:
loadingSession - Persisting the session with
useLocalStorage - Tokens, cookies, and the limits of browser storage
- Hiding in the interface what can't be used
- The definitive route map
- CicloUrbano's session model
There's nothing to invent here: UserContext has existed since 05-04, in exactly the shape you need.
// src/contexts/UserContext.jsx — what you already have
export function UserProvider({ children, initialUser = null }) {
const [user, setUser] = useState(initialUser);
function signIn(id) {
const found = users.find((u) => u.id === id);
setUser(found ?? null);
}
function signOut() {
setUser(null);
}
const value = {
user,
isOperator: user?.role === 'operario',
signIn,
signOut
};
return <UserContext value={value}>{children}</UserContext>;
}
export function useUser() {
const context = useContext(UserContext);
if (context === null) {
throw new Error('useUser must be used inside <UserProvider>');
}
return context;
}The project's two fictitious profiles:
| User | Name | Role | What they can see | |
|---|---|---|---|---|
usr-01 |
Ana Ribera | [email protected] |
cliente |
Catalogue, stations, their bookings |
usr-02 |
Marc Solé | [email protected] |
operario |
Everything above plus /taller |
And three possible session states, worth telling apart carefully because the third one causes the most trouble:
stateDiagram-v2
[*] --> Checking: app starts
Checking --> NoSession: no stored session
Checking --> HasSession: session recovered
NoSession --> HasSession: signIn()
HasSession --> NoSession: signOut()
note right of Checking
In-between state.
Neither redirect nor show
protected content:
show a loading indicator.
end note
One necessary change from 05-04: back then initialUser was users[0], because someone was always signed in. Now the initial value is null, because "nobody has signed in" has to be a representable state. Once a sign-in screen exists, starting with a session would be a contradiction.
- The sign-in screen
A fictitious sign-in form, with no passwords, that only picks between the two profiles. In a real application there would be credentials here and a call to the API; for learning routing, that would only add noise.
// src/pages/SignInPage.jsx
import { useState } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import { users } from '../data/domain.js';
import Notice from '../components/Notice.jsx';
import styles from './SignInPage.module.css';
function SignInPage() {
const { user, signIn } = useUser();
const navigate = useNavigate();
const location = useLocation();
const [chosenId, setChosenId] = useState('usr-01');
// Where to go back to: the guard left it here when it redirected (section 4)
const destination = location.state?.returnTo?.pathname ?? '/';
// Whoever already has a session shouldn't see the form
if (user) {
return <Navigate to={destination} replace />;
}
function handleSubmit(event) {
event.preventDefault();
signIn(chosenId);
// replace: "back" shouldn't return to the sign-in form
navigate(destination, { replace: true });
}
return (
<section className={styles.signIn}>
<h2>Sign in to CicloUrbano</h2>
{location.state?.returnTo && (
<Notice tone="info" title="You need to sign in">
<p>
The <code>{location.state.returnTo.pathname}</code> screen requires
a signed-in session. We'll take you there as soon as you're in.
</p>
</Notice>
)}
<form onSubmit={handleSubmit}>
<fieldset>
<legend>Choose a demo profile</legend>
{users.map((candidate) => (
<p key={candidate.id}>
<label>
<input
type="radio"
name="profile"
value={candidate.id}
checked={chosenId === candidate.id}
onChange={(event) => setChosenId(event.target.value)}
/>{' '}
{candidate.name} — <span>{candidate.role}</span>
<br />
<small>{candidate.email}</small>
</label>
</p>
))}
</fieldset>
<button type="submit">Sign in</button>
</form>
<p className={styles.note}>
Fictitious demo data. None of these accounts is real or requires a
password.
</p>
</section>
);
}
export default SignInPage;Four details worth calling out:
- The form is controlled (03-04):
checkedcomes from state andonChangeupdates it. The radio buttons share anameso they're mutually exclusive. <fieldset>and<legend>group the set of options and are what a screen reader announces before reading them, as seen in 03-06.- Whoever already has a session doesn't see the form:
<Navigate to={destination} replace />during render, the pattern from 06-04. replaceon submission, for the same reason: after signing in, "back" shouldn't return to the sign-in page.
- Pattern 1: the
ProtectedRoute guard component
ProtectedRoute guard componentThe first pattern wraps directly around the content that needs protecting.
// src/components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
/**
* Session guard. Renders its children only if a user is signed in;
* otherwise redirects to /acceso, remembering the original destination.
*
* ⚠️ User experience only: real authorization lives on the server.
*
* Props:
* - children (content, required): what gets protected
*/
function ProtectedRoute({ children }) {
const { user } = useUser();
const location = useLocation();
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
return children;
}
export default ProtectedRoute;Used on the map:
The three decisions in this component, one by one:
<Navigate />, not useNavigate in an effect. The decision can be made just by looking at state during render, so 06-04's rule applies. With an effect there'd be a moment where the protected screen renders before the redirect happens: a visible flash of the content you meant to hide, on top of the risk of a loop.
replace, not a new entry. Without it, the history would read … → /taller → /acceso, and pressing "back" would send the user right back to /taller, which would redirect to /acceso again, which on going back would… A bounce with no way out. With replace, the /taller entry gets replaced, and "back" leads to wherever the user was before.
state={{ returnTo: location }} saves the original destination. That's the next section, and it's the difference between access control that's acceptable and one that's irritating.
- Returning to the original destination after signing in
Without this piece, the experience is: an operator gets the link /taller over chat, opens it, sees the sign-in form, signs in… and lands on the catalogue. They have to go back to the chat and click the link again. With the state, they sign in and land straight on the workshop.
sequenceDiagram
participant U as User
participant T as /taller
participant G as ProtectedRoute
participant A as /acceso
U->>T: Opens the shared link
T->>G: The guard renders
G->>G: user === null
G->>A: Navigate replace<br/>state: { returnTo: { pathname: '/taller' } }
A-->>U: Form + "You need to sign in"
U->>A: Picks usr-02 and submits
A->>A: signIn('usr-02')
A->>T: navigate('/taller', { replace: true })
T-->>U: Operator panel ✅
The whole location object gets saved, not just the pathname, so the query string survives too:
// Saved by the guard
state={{ returnTo: location }}
// Read in SignInPage, rebuilding the full URL
const from = location.state?.returnTo;
const destination = from ? `${from.pathname}${from.search}` : '/';That way, whoever was trying to open /taller?filtro=urgentes goes back exactly there.
A security precaution worth knowing about from the start. If the destination came from a query parameter instead of from state — common in applications that plug into an external sign-in system — you'd have an open redirect: someone could send /acceso?returnTo=https://fake-site.test, and your application would send the user to a foreign site right after signing in, with every appearance of legitimacy. The defence is to accept only internal paths:
function safeDestination(candidate) {
// Must start with a single slash: neither '//other.test' nor 'https://…'
if (typeof candidate !== 'string') return '/';
if (!candidate.startsWith('/') || candidate.startsWith('//')) return '/';
return candidate;
}With React Router's state the risk is much lower, because your own guard writes it and it never travels in the URL, but the check costs four lines and the pattern is worth having internalized.
- Pattern 2: the protected pathless route
Pattern 1 works fine with one screen. With four, the map fills up with repeated wrappers:
// ❌ Repetitive and easy to forget on the fifth screen
{ path: 'taller', element: <ProtectedRoute><WorkshopPage /></ProtectedRoute> },
{ path: 'informes', element: <ProtectedRoute><ReportsPage /></ProtectedRoute> },
{ path: 'flota', element: <ProtectedRoute><FleetPage /></ProtectedRoute> }This is where the pathless route from 06-03 comes in: a route whose element is the guard and that groups several children, without adding any segment to the URL.
// src/components/ProtectedRoute.jsx — version for a pathless route
import { Navigate, useLocation, Outlet } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
function ProtectedRoute() {
const { user } = useUser();
const location = useLocation();
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
return <Outlet />; // ← instead of children
}
export default ProtectedRoute;// src/routes.jsx — the protected branch
{
element: <ProtectedRoute />, // ← no path: doesn't consume a segment
children: [
{ path: 'taller', element: <WorkshopPage /> },
{ path: 'informes', element: <ReportsPage /> }
]
}The URLs are still /taller and /informes. Comparing the two patterns:
| Pattern 1: wrap children | Pattern 2: pathless route | |
|---|---|---|
| How it's declared | element: <ProtectedRoute><X /></ProtectedRoute> |
A route with no path, with children |
| What the guard renders | children |
<Outlet /> |
| With one protected screen | Simple and direct | Adds a level to the map |
| With several | Repeats on each one | Declared once |
| Risk of forgetting to protect a new one | High | Low: it's added inside the branch |
| Shared UI for the group | Has to be repeated | The guard can paint a sidebar, breadcrumbs… |
| Recommendation | One-off cases | Preferable once there are several screens |
That second-to-last row is a nice bonus: since the guard is a regular route component, it can paint the shared frame of the private area on top of checking the session.
function ProtectedRoute() {
const { user } = useUser();
const location = useLocation();
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
return (
<div className={styles.privateArea}>
<p className={styles.signedInAs}>
Signed in as <strong>{user.name}</strong> ({user.role})
</p>
<Outlet />
</div>
);
}And the resulting route tree, with the protected branch marked:
flowchart TD
ROOT["/ · Layout"]
ROOT --> IDX["index · CataloguePage"]
ROOT --> BICI["bicicletas/:bicicletaId"]
ROOT --> EST["estaciones"]
EST --> ESTI["index · StationsPage"]
EST --> DET["' :estacionId ' · StationDetailPage"]
DET --> FLO["index · FleetTab"]
DET --> INC["incidencias · IncidentsTab"]
ROOT --> RES["reservas"]
RES --> RESI["index · BookingsPage"]
RES --> NUE["nueva · NewBookingPage"]
ROOT --> ACC["acceso · SignInPage"]
ROOT --> PROT["🔒 (no path) ProtectedRoute"]
PROT --> ROL["🔒 (no path) RequireRole operario"]
ROL --> TAL["taller · WorkshopPage"]
ROOT --> NF["* · NotFoundPage"]
style PROT fill:#fde68a
style ROL fill:#fed7aa
style TAL fill:#fecaca
- Role-based authorization:
RequireRole
RequireRoleHaving a session and having permission are different things. Ana Ribera is signed in, and she still shouldn't be able to enter /taller. It's worth keeping the two concepts in two separate components:
| Concept | Question | Component | On failure |
|---|---|---|---|
| Authentication | Who are you? | ProtectedRoute |
Redirects to /acceso |
| Authorization | Can you do this? | RequireRole |
Shows "forbidden" (403) |
// src/components/RequireRole.jsx
import { Outlet, Navigate, useLocation } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import ForbiddenPage from '../pages/ForbiddenPage.jsx';
/**
* Role-based authorization guard.
*
* ⚠️ User experience only: real authorization lives on the server.
*
* Props:
* - allowedRoles (array of strings, required): e.g. ['operario']
* - children (content, optional): if missing, <Outlet /> is used
*/
function RequireRole({ allowedRoles, children }) {
const { user } = useUser();
const location = useLocation();
// No session: that's an authentication problem, not a permissions one
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
// Signed in but without the right role: 403, and the URL is kept
if (!allowedRoles.includes(user.role)) {
return <ForbiddenPage allowedRoles={allowedRoles} />;
}
return children ?? <Outlet />;
}
export default RequireRole;The children ?? <Outlet /> at the end lets you use the same component in both patterns: wrapping children, or as a pathless route. It's a small convenience that saves you from maintaining two nearly identical components.
On the map, the two guards nest, and each does its own job:
{
element: <ProtectedRoute />, // is there a session?
children: [
{
element: <RequireRole allowedRoles={['operario']} />, // are they an operator?
children: [
{ path: 'taller', element: <WorkshopPage />, handle: { crumb: 'Workshop' } }
]
}
]
}Why two levels and not just one? Because they separate responsibilities and because they're reusable on their own: tomorrow /reservas might need a session but accept any role, and /informes might allow operario and supervisor. Each guard does one thing, and they combine as needed. Strictly speaking, RequireRole already covers the no-session case on its own, so you could use it alone; nesting expresses the intent better and makes it obvious on the map that there are two separate checks.
- 403 and 404: why they're different screens
It's tempting to reuse NotFoundPage when someone lacks permission, and it's a mistake. The two cases tell the user different things:
| 404 "Not found" | 403 "Forbidden" | |
|---|---|---|
| What it means | This address doesn't exist | It exists, but you can't see it |
| Whose fault | A typo or a broken link | The profile you're signed in with |
| What the user can do | Fix the URL, go home | Sign in with another account, or ask for access |
| Suggested action | "Back to catalogue" | "Switch account" / "Request access" |
| Confusion if mixed up | The user thinks the screen doesn't exist and stops trying | — |
// src/pages/ForbiddenPage.jsx
import { Link, useLocation } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import Notice from '../components/Notice.jsx';
/**
* CicloUrbano's 403 screen.
* Props:
* - allowedRoles (array of strings, optional): to explain what's needed
*/
function ForbiddenPage({ allowedRoles = [] }) {
const { user, signOut } = useUser();
const location = useLocation();
return (
<section>
<Notice tone="warning" title="You don't have permission to view this screen">
<p>
You signed in as <strong>{user?.name}</strong> with the role{' '}
<strong>{user?.role}</strong>. The <code>{location.pathname}</code>{' '}
screen is only for {allowedRoles.join(' or ') || 'other profiles'}.
</p>
<p>
If you think you should have access, talk to whoever is responsible
for CicloUrbano's fleet.
</p>
</Notice>
<p>
<Link to="/">Back to catalogue</Link> ·{' '}
<button type="button" onClick={signOut}>
Sign in with another account
</button>
</p>
</section>
);
}
export default ForbiddenPage;It renders in place, it doesn't redirect. Keeping the /taller URL has two advantages: the user can see which screen they tried to open and can fix the problem (switch account) without having to find the link again; and when they report the issue, the address is half the report.
A subtlety seen in applications with strict confidentiality requirements: sometimes a 404 is returned instead of a 403 on purpose, so as not to reveal that the screen exists at all. It's a legitimate decision, but it's a product and security decision that has to be made consciously, with the team — not an oversight.
- The in-between state:
loadingSession
loadingSessionHere's the most visible failure of a badly built guard, and it shows up the moment the session gets recovered from somewhere: during the very first render, user is still null because nothing has been read yet. The guard reads that as "no session" and redirects to /acceso someone who actually had one. The user sees a flash of the sign-in form and then, if they're lucky, a jump back.
The fix is to represent the third state from section 1's diagram: "I don't know yet."
// src/contexts/UserContext.jsx — version with a loading state
import { createContext, useContext, useState, useEffect } from 'react';
import { users } from '../data/domain.js';
const UserContext = createContext(null);
const SESSION_KEY = 'ciclourbano:sesion';
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loadingSession, setLoadingSession] = useState(true); // ← third state
useEffect(() => {
// Recovering the session on start-up. In a real application, this is
// where the token would be validated against the server: asynchronously.
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) {
const id = JSON.parse(stored);
setUser(users.find((u) => u.id === id) ?? null);
}
} catch {
// Storage blocked or corrupted data: start with no session
} finally {
setLoadingSession(false); // whatever happened, the check is done
}
}, []);
function signIn(id) {
const found = users.find((u) => u.id === id) ?? null;
setUser(found);
try {
if (found) localStorage.setItem(SESSION_KEY, JSON.stringify(found.id));
} catch { /* no space or no permission: the session lives in memory only */ }
}
function signOut() {
setUser(null);
try {
localStorage.removeItem(SESSION_KEY);
} catch { /* ignored on purpose */ }
}
const value = {
user,
isOperator: user?.role === 'operario',
loadingSession,
signIn,
signOut
};
return <UserContext value={value}>{children}</UserContext>;
}
export function useUser() {
const context = useContext(UserContext);
if (context === null) {
throw new Error('useUser must be used inside <UserProvider>');
}
return context;
}And the guards respect it before deciding anything:
function ProtectedRoute() {
const { user, loadingSession } = useUser();
const location = useLocation();
// 1. Still unknown: neither redirect nor show protected content
if (loadingSession) {
return <LoadingIndicator message="Checking your session…" />;
}
// 2. Known, and there's no session
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
// 3. Known, and there's a session
return <Outlet />;
}// src/components/LoadingIndicator.jsx
import styles from './LoadingIndicator.module.css';
/**
* Accessible waiting indicator.
* Props:
* - message (string, optional, defaults to 'Loading…')
*/
function LoadingIndicator({ message = 'Loading…' }) {
return (
<p className={styles.indicator} role="status" aria-live="polite">
<span className={styles.spinner} aria-hidden="true" />
{message}
</p>
);
}
export default LoadingIndicator;role="status" with aria-live="polite" makes a screen reader announce the message without interrupting whatever it's currently reading, as established in 03-06. The animated spinner carries aria-hidden because it's purely decorative.
Three rules for the in-between state:
- While
loadingSessionistrue, nothing gets decided. Neither redirecting nor showing protected content. SignInPagehas to respect it too. Otherwise the form flashes right before the session is recovered and redirects.- Watch out for artificial waiting. If the check is instant, an indicator that appears and disappears in 30 milliseconds is more annoying than helpful: apply the 200 ms delay from 06-04's
ProgressBar.
- Persisting the session with
useLocalStorage
useLocalStorageThe previous section wrote the localStorage access by hand, so you could see the mechanics together with the finally. But the project already has the hook from 05-06, and using it simplifies things quite a bit:
// src/contexts/UserContext.jsx — with useLocalStorage
import { useLocalStorage } from '../hooks/useLocalStorage.js';
export function UserProvider({ children }) {
// The hook reads lazily on the first render: there's no in-between state
const [storedId, setStoredId] = useLocalStorage('ciclourbano:sesion', null);
const user = storedId ? users.find((u) => u.id === storedId) ?? null : null;
const value = {
user,
isOperator: user?.role === 'operario',
loadingSession: false, // synchronous read: never any uncertainty
signIn: (id) => setStoredId(id),
signOut: () => setStoredId(null)
};
return <UserContext value={value}>{children}</UserContext>;
}Notice that user is a derived value from the stored identifier, not a second piece of state: exactly the distinction from 02-04 and 05-01. Storing the whole object as well as the identifier would create two sources of truth that could drift apart.
And that loadingSession is false because useLocalStorage reads synchronously in useState's lazy initializer. That's the nuance that decides whether you need the in-between state at all:
| Where the session comes from | Is it synchronous? | Do you need loadingSession? |
|---|---|---|
localStorage via useLocalStorage |
Yes | No |
| A cookie read with JavaScript | Yes | No |
| Validating the token against the server | No | Yes |
| An external identity library | No | Yes |
| Silent token refresh | No | Yes |
As soon as CicloUrbano's session gets validated against an API — the normal case in production — the in-between state becomes essential again. That's why it was worth seeing.
- Tokens, cookies, and the limits of browser storage
Here it's worth being explicit about what's being stored and what should never be stored there.
What we've stored: the identifier 'usr-02'. It's an interface preference, like the dark theme: if a user edits it by hand in localStorage, all they achieve is that their own interface shows buttons the server will reject the moment they're used. There's nothing to steal.
What should never be stored there: passwords, API keys, other people's personal data, and, with important caveats, session tokens.
The problem with localStorage and a token is concrete: any JavaScript running on your page can read it. An XSS vulnerability — user input painted without sanitising, a compromised dependency — turns stealing the token into a single line of code, and with that token the attacker acts as the user from anywhere.
The usual alternative is httpOnly cookies:
localStorage |
httpOnly + Secure + SameSite cookie |
|
|---|---|---|
| Readable by JavaScript | Yes | No: the browser sends it, the code never sees it |
| Sent automatically | No: has to be added to every header | Yes, on every request to the domain |
| Exposed to XSS | Yes | Much less |
| Exposed to CSRF | No | Yes, requires SameSite and an anti-CSRF token |
| Requires server collaboration | No | Yes: only the server can set it |
| Works across different domains | Yes, manually | Requires careful configuration |
And here it's worth being honest about the scope of this course: choosing between one and the other, how long tokens last, silent refresh, revocation, and CSRF protection aren't decisions a frontend developer makes alone. They're architecture decisions made with the team responsible for security and the server, because half the solution lives there: only the server can set an httpOnly cookie. If you ever find yourself deciding this on your own on the client in a real project, the right move is to raise it with the team, not to pick whichever option seems most convenient.
What genuinely is your responsibility on the client, and it's not nothing:
- Never write secrets into the code. Everything in the JavaScript bundle is public, including Vite environment variables starting with
VITE_. - Don't put sensitive data in the URL, which gets stored in history, shared, and logged on the server.
- Never trust a piece of client data to decide a permission on the server.
- Actually clear the session when signing out, and tell the server to invalidate it.
- Sanitise everything painted as HTML. React escapes content by default — you saw this in 01-04 — and
dangerouslySetInnerHTMLis called that for a reason.
- Hiding in the interface what can't be used
Protecting the route keeps the screen from being seen; hiding the link keeps the user from getting all the way there just to be turned away. The two go together, and they're complementary, not alternatives.
// src/components/UserMenu.jsx — session and role reflected in the menu
import { NavLink, Link, useNavigate } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import { useTheme } from '../contexts/ThemeContext.jsx';
import ThemeButton from './ThemeButton.jsx';
import styles from './UserMenu.module.css';
function UserMenu() {
const { user, isOperator, signOut } = useUser();
const navigate = useNavigate();
function handleSignOut() {
signOut();
// On signing out, get out of any protected screen
navigate('/', { replace: true });
}
if (!user) {
return (
<div className={styles.menu}>
<ThemeButton />
<Link to="/acceso" className={styles.signIn}>
Sign in
</Link>
</div>
);
}
return (
<div className={styles.menu}>
<ThemeButton />
<span className={styles.name}>{user.name}</span>
{/* The link to the workshop only exists for the operator */}
{isOperator && (
<NavLink to="/taller" className={styles.link}>
Workshop panel
</NavLink>
)}
<button type="button" onClick={handleSignOut}>
Sign out
</button>
</div>
);
}
export default UserMenu;The same criterion applies inside the screens: BikeCard has read isOperator from context since 05-04 to show the "Send to workshop" button only to whoever can use it.
And the sign-out detail that's easy to forget: if the user signs out while on /taller, they need to be taken out of there. Without that navigate('/', { replace: true }), the guard would catch it and redirect to /acceso, which works but is disorienting: the user clicked "Sign out" and ends up on a screen asking them to sign in. Taking them to the catalogue is what they expect.
Hiding versus disabling, since it's a recurring design decision:
| Approach | When | Example |
|---|---|---|
| Hide | The option will never be available for this profile | "Workshop panel" for a customer |
| Disable with an explanation | It could become available if something changed | "Book" on a bike that's in maintenance |
| Show and explain on click | You want the feature to be known about | An option from a higher-tier plan |
Hiding everything indiscriminately has a cost: a user who can't see an option has no way of knowing it exists or of asking for access to it. For role-based features, hiding is the norm; for temporary states, disabling with an explanation is better.
- The definitive route map
Here's how src/routes.jsx looks at the end of the module, bringing together everything built across the five lessons:
// src/routes.jsx — definitive version for module 6
import { createBrowserRouter } from 'react-router';
import Layout from './components/Layout.jsx';
import ProtectedRoute from './components/ProtectedRoute.jsx';
import RequireRole from './components/RequireRole.jsx';
import CataloguePage from './pages/CataloguePage.jsx';
import BikeDetailPage from './pages/BikeDetailPage.jsx';
import StationsPage from './pages/StationsPage.jsx';
import StationDetailPage from './pages/StationDetailPage.jsx';
import FleetTab from './pages/FleetTab.jsx';
import IncidentsTab from './pages/IncidentsTab.jsx';
import BookingsFrame from './components/BookingsFrame.jsx';
import BookingsPage from './pages/BookingsPage.jsx';
import NewBookingPage from './pages/NewBookingPage.jsx';
import SignInPage from './pages/SignInPage.jsx';
import WorkshopPage from './pages/WorkshopPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import RouteErrorPage from './pages/RouteErrorPage.jsx';
import { stations } from './data/domain.js';
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <RouteErrorPage />,
handle: { crumb: 'Home' },
children: [
{ index: true, element: <CataloguePage />, handle: { crumb: 'Catalogue' } },
{
path: 'bicicletas/:bicicletaId',
element: <BikeDetailPage />,
handle: { crumb: 'Bike details' }
},
{
path: 'estaciones',
handle: { crumb: 'Stations' },
children: [
{ index: true, element: <StationsPage /> },
{
path: ':estacionId',
element: <StationDetailPage />,
handle: {
crumb: (params) =>
stations.find((st) => st.id === params.estacionId)?.name ?? 'Station'
},
children: [
{ index: true, element: <FleetTab /> },
{ path: 'incidencias', element: <IncidentsTab /> }
]
}
]
},
{
path: 'reservas',
element: <BookingsFrame />,
handle: { crumb: 'My bookings' },
children: [
{ index: true, element: <BookingsPage /> },
{ path: 'nueva', element: <NewBookingPage />, handle: { crumb: 'New booking' } }
]
},
{ path: 'acceso', element: <SignInPage />, handle: { crumb: 'Sign in' } },
// Protected branch: session required, without adding a segment to the URL
{
element: <ProtectedRoute />,
children: [
{
element: <RequireRole allowedRoles={['operario']} />,
children: [
{ path: 'taller', element: <WorkshopPage />, handle: { crumb: 'Workshop' } }
]
}
]
},
{ path: '*', element: <NotFoundPage /> }
]
}
]);Check the result against this table of scenarios:
| Who | URL | What they see |
|---|---|---|
| No session | / |
Catalogue, with "Sign in" in the menu |
| No session | /taller |
Redirected to /acceso, with a notice and a return afterwards |
Ana (usr-01, customer) |
/ |
Catalogue, with no link to the workshop |
| Ana | /taller |
403 "Forbidden" screen, URL kept |
Marc (usr-02, operator) |
/taller |
Workshop panel ✅ |
| Marc | /estaciones/est-02/incidencias |
Incidents tab, with the operator's buttons |
| Anyone | /estacionez |
404 "Not found" screen |
Common Mistakes and Tips
Thinking this is security. The lesson's underlying mistake. A route guard is user experience. If the server doesn't check permissions on every request, there is no protection of any kind.
Redirecting during the loading state. If the guard decides before knowing whether there's a session, it throws out legitimate users. Check loadingSession before anything else.
Forgetting replace when redirecting to /acceso. The history ends up as /taller → /acceso, and "back" produces an infinite bounce between the two.
Losing the original destination. Without state={{ returnTo: location }}, whoever opens a protected link ends up on the catalogue after signing in and has to go find the link again.
Using the 404 screen for 403 cases. The user thinks the screen doesn't exist and it doesn't occur to them to sign in with another account.
Redirecting instead of showing the 403. By losing the URL, the user doesn't know what they were trying to open and can't fix it.
Hiding the link without protecting the route. Typing the address by hand is enough to bypass it. Do both.
Protecting the route and not the API. It's the same as protecting nothing, but with the false sense of having done so.
Storing tokens in localStorage without thinking it through. Any XSS exposes them. It's an architecture decision for the team, not a client-side implementation detail.
Not getting the user out of a protected screen on sign-out. They end up on the sign-in form right after clicking "Sign out": disorienting.
Tip: declare permissions on the route map. With handle (06-03), the map becomes the single source of truth and can be walked to generate the menu automatically:
Tip: always test all four cases. No session, session with an insufficient role, session with the right role, and reloading (F5) on each one. The reload is what uncovers problems with the in-between state.
Tip: write the warning into the code itself. A comment at the top of ProtectedRoute reminding whoever reads it that real authorization lives on the server keeps someone — maybe you, a year from now — from assuming that component protects something on its own.
Exercises
Exercise 1: booking requires a session
A visitor with no session can currently open /reservas/nueva and fill in the form. Protect that screen, but not /reservas, which should stay visible so it can show the empty state with an invitation to sign in. Requirements:
/reservas/nuevarequires a session, of any role.- After signing in, the user returns to
/reservas/nueva. BookingsFramekeeps working on both.
State which of the two patterns you'd use and why.
Exercise 2: menu generated from the map
Add roles to the handle of the routes that need it, and write a MainMenu component that generates Header's links by walking the route map, showing only the ones the current user can visit. Explain what advantage this has over a hand-written list.
Exercise 3: find the guard's bugs
This guard has four problems. Identify them and fix it.
function ProtectedRoute({ children }) {
const { user } = useUser();
const navigate = useNavigate();
useEffect(() => {
if (!user) {
navigate('/acceso');
}
}, []);
return children;
}Solutions
Solution 1
Pattern 2 (the protected pathless route), even though there's only one screen, for two reasons: it keeps BookingsFrame as the shared parent of both children without duplicating it, and it leaves the branch ready for when there are more booking screens that require a session (editing, cancelling). With pattern 1 you'd have to wrap each child's element, and it would be easy to forget on the next one.
{
path: 'reservas',
element: <BookingsFrame />,
handle: { crumb: 'My bookings' },
children: [
// Public: the empty state invites the user to sign in
{ index: true, element: <BookingsPage /> },
// Protected: a pathless branch inside the frame
{
element: <ProtectedRoute />,
children: [
{ path: 'nueva', element: <NewBookingPage />, handle: { crumb: 'New booking' } }
]
}
]
}And BookingsPage tells the two empty-state cases apart:
function BookingsPage() {
const { state } = useBookings();
const { user } = useUser();
if (state.bookings.length === 0) {
return user ? (
<p>
You don't have any bookings yet. <Link to="/reservas/nueva">Create one</Link>
</p>
) : (
<p>
<Link to="/acceso">Sign in</Link> to see your bookings and create a new one.
</p>
);
}
return <BookingsPanel bookings={state.bookings} />;
}Returning to the original destination works without writing anything more: ProtectedRoute saves state={{ returnTo: location }} and SignInPage consumes it.
Solution 2
// src/routes.jsx — roles added to the handle where needed
{ index: true, element: <CataloguePage />, handle: { crumb: 'Catalogue', inMenu: true } }
{ path: 'estaciones', handle: { crumb: 'Stations', inMenu: true }, children: [ /* … */ ] }
{ path: 'reservas', handle: { crumb: 'My bookings', inMenu: true, requiresSession: true }, /* … */ }
{ path: 'taller', element: <WorkshopPage />, handle: { crumb: 'Workshop', inMenu: true, roles: ['operario'] } }// src/components/MainMenu.jsx
import { NavLink } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import { router } from '../routes.jsx';
import styles from './Header.module.css';
/**
* Walks the route map and returns the visible menu entries.
*/
function collectEntries(routes, prefix = '') {
return routes.flatMap((route) => {
const path = route.index
? prefix || '/'
: [prefix, route.path].filter(Boolean).join('/').replace('//', '/');
const own = route.handle?.inMenu
? [{ to: path.startsWith('/') ? path : `/${path}`, handle: route.handle, index: Boolean(route.index) }]
: [];
const children = route.children ? collectEntries(route.children, route.path ? path : prefix) : [];
return [...own, ...children];
});
}
function MainMenu() {
const { user } = useUser();
const entries = collectEntries(router.routes).filter(({ handle }) => {
if (handle.requiresSession && !user) return false;
if (handle.roles && !handle.roles.includes(user?.role)) return false;
return true;
});
return (
<nav aria-label="Main navigation">
{entries.map((entry) => (
<NavLink
key={entry.to}
to={entry.to}
end={entry.index}
className={({ isActive }) =>
isActive ? `${styles.link} ${styles.active}` : styles.link
}
>
{typeof entry.handle.crumb === 'function' ? entry.handle.crumb({}) : entry.handle.crumb}
</NavLink>
))}
</nav>
);
}
export default MainMenu;The advantages over a hand-written list: the route map becomes the single source of truth, so adding a screen to the menu means adding inMenu: true to its route — it's impossible for the menu and the routes to drift apart; visibility rules are declared right next to the route they protect, instead of being repeated in the header; and if a path changes tomorrow, the link updates itself.
The cost, worth acknowledging: walking the map to build the paths is fiddly with index routes, pathless routes, and nested routes — hence how convoluted collectEntries gets — and for a three-entry menu it can be more code than it saves. In an application with twenty screens and several roles, it pays off handsomely. It's the same judgment call as always: the abstraction has a cost, and you need to know when it's been earned back.
Solution 3
The four problems:
return childrenalways runs, even with no session. The effect runs after the render, so the protected content gets painted for an instant before the redirect: exactly what you wanted to avoid. It's the most serious bug.- Empty dependency array. If the user signs out while inside, the effect never runs again and the guard stops protecting it. It needs to include
userandnavigate. replaceis missing. The history ends up as/taller → /acceso, and "back" bounces indefinitely.- The original destination isn't saved. After signing in, the user ends up wherever the form sends them, not where they wanted to go.
And a fifth, lying dormant as soon as the session is ever recovered asynchronously: loadingSession isn't checked, so it would throw out users with a valid session during the first render.
The corrected version is the one from section 8:
function ProtectedRoute({ children }) {
const { user, loadingSession } = useUser();
const location = useLocation();
if (loadingSession) {
return <LoadingIndicator message="Checking your session…" />;
}
if (!user) {
return <Navigate to="/acceso" replace state={{ returnTo: location }} />;
}
return children ?? <Outlet />;
}The underlying lesson: deciding during render with <Navigate /> instead of in an effect clears up problems 1, 2, and much of 3 in one go, because there's never a moment when the protected content gets to exist. It's the direct application of 06-04's rule.
Conclusion
We started and we end on the same note, because it's the one thing in this lesson that admits no nuance: all the access control you've written is user experience, not security. The code downloads in full to the user's browser, and there they can change variables, step past breakpoints, edit localStorage, and call your API directly without going through the interface at all. A route guard keeps a legitimate user from seeing screens that aren't meant for them and keeps the application coherent; real authorization is always checked on the server, on every request, with the credentials of whoever is making it. If the server hands the workshop's data to anyone who asks for it, ProtectedRoute protects nothing.
That said, you've built complete, well-made access control. Reusing 05-04's UserContext — user, isOperator, signIn, signOut — and with a fictitious sign-in at /acceso that chooses between Ana Ribera (usr-01, customer) and Marc Solé (usr-02, operator), you now have two patterns at your disposal: the guard component that wraps what it protects and redirects with <Navigate to="/acceso" replace state={{ returnTo: location }} />, and the protected pathless route — a route with no path whose element is the guard and that groups several children with <Outlet /> — preferable once there's more than one screen, because it's declared once, it's impossible to forget when adding the next one, and it can paint the private area's shared frame. On top of them, RequireRole separates authentication ("who are you?") from authorization ("can you?"), with a 403 screen that keeps the URL, explains which role you're signed in with, and offers to switch accounts — clearly distinct from the 404 that says the address doesn't exist: mixing them up leaves the user thinking the screen isn't there at all. You've solved the in-between state with loadingSession and an accessible indicator, so nobody gets thrown out while it's still being checked whether they have a session — essential as soon as validation is asynchronous — you've persisted the session with 05-06's useLocalStorage, storing only the identifier and deriving the user, and you know that choosing between localStorage and httpOnly cookies for a token is an architecture decision to raise with the security team, not something decided alone on the client. And you've completed the picture by hiding, in the menu, what the role can't use, on top of protecting the route: both things, never just one.
With this, Module 6 closes. CicloUrbano has gone from a single screen to a genuinely routed application: nine routes with Layout as a persistent root, dynamic segments for bikes and stations, filters in the URL, nested tabs, breadcrumbs generated straight from the map itself, per-branch error containment, programmatic navigation after confirming a booking, exit-blocking for unsaved changes, and a branch protected by both session and role.
And the next problem appears, and it's already noticeable in the code you've just written. State is scattered across the whole application: the session in UserContext, the theme in ThemeContext, notices in NoticesContext, bookings in a reducer inside BookingsContext, the catalogue filter in the URL, and the search term in a page's useState. Each one lives somewhere different for a different reason, some components consume three contexts at once, and it's no longer clear where the next piece of data that shows up should live, or how to keep a change in one context from repainting half the application. Module 7: State Management brings order to all of this: what types of state exist and where each one should live, how far context can go as a global strategy and what it really costs, what Redux brings with its single store, its actions and its reducers, how it connects to React, and why data that comes from a server is a category of its own with its own tools. The next lesson is Introduction to State Management.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
