Protected Routes con React Router: control de acceso limpio y escalable
Una de las primeras necesidades cuando una aplicación empieza a crecer es restringir el acceso a determinadas páginas. El panel de administración, el perfil del usuario, la configuración de la cuenta o la facturación no deberían estar disponibles para cualquiera que conozca la URL.
Un error bastante común consiste en ocultar enlaces del menú y asumir que eso ya protege la aplicación. En realidad, cualquier usuario puede escribir la ruta manualmente en el navegador. La validación debe realizarse en el sistema de enrutado y, por supuesto, también en el servidor.
React Router facilita este patrón sin necesidad de soluciones complejas. La idea consiste en crear componentes reutilizables que decidan si una ruta puede renderizarse o si el usuario debe ser redirigido.
Qué es una Protected Route
Una Protected Route es un componente que comprueba el estado de autenticación antes de renderizar una página.
Si el usuario ha iniciado sesión, muestra el contenido solicitado.
Si no está autenticado, lo redirige a otra ruta, normalmente /login.
El flujo suele ser este:
Usuario solicita /dashboard
│
▼
¿Está autenticado?
│
┌────┴─────┐
│ │
Sí No
│ │
▼ ▼
Render Redirect
Dashboard /login
Este patrón mantiene toda la lógica de autorización en un único lugar y evita repetir comprobaciones en cada componente.
Proyecto base
Para este ejemplo se utilizará React Router v7 con una estructura sencilla.
src/
│
├── App.jsx
├── main.jsx
│
├── pages/
│ ├── Login.jsx
│ ├── Dashboard.jsx
│ ├── Profile.jsx
│ └── Home.jsx
│
├── auth/
│ ├── AuthContext.jsx
│ └── ProtectedRoute.jsx
│
└── components/
└── Navbar.jsx
La separación entre páginas, autenticación y componentes facilita el mantenimiento cuando la aplicación empieza a incorporar más funcionalidades.
Creando un contexto de autenticación
Lo primero es disponer de un estado global que indique si existe una sesión activa.
// auth/AuthContext.jsx
import { createContext, useContext, useState } from "react";
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
function login() {
setUser({
id: 1,
name: "Carlos",
role: "admin",
});
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider
value={{
user,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
Aquí el usuario se almacena en memoria únicamente para simplificar el ejemplo.
En una aplicación real, normalmente el estado se inicializa a partir de:
- JWT almacenado en cookies
- Access Token
- Refresh Token
- Sesión validada mediante una llamada a la API
Registrando el proveedor
En main.jsx se envuelve toda la aplicación.
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import { AuthProvider } from "./auth/AuthContext";
ReactDOM.createRoot(document.getElementById("root")).render(
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
);
De esta forma cualquier componente puede consultar el estado del usuario.
Creando la Protected Route
Aquí está la pieza principal.
// auth/ProtectedRoute.jsx
import { Navigate, Outlet } from "react-router";
import { useAuth } from "./AuthContext";
export default function ProtectedRoute() {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
Outlet renderiza las rutas hijas únicamente cuando el acceso está permitido.
Si no existe un usuario autenticado, React Router reemplaza la navegación actual por /login.
El uso de replace evita que el usuario vuelva con el botón "Atrás" hacia una página protegida que ya no debería visualizar.
Configurando las rutas
import {
Routes,
Route,
} from "react-router";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Profile from "./pages/Profile";
import ProtectedRoute from "./auth/ProtectedRoute";
function App() {
return (
<Routes>
<Route
path="/"
element={<Home />}
/>
<Route
path="/login"
element={<Login />}
/>
<Route element={<ProtectedRoute />}>
<Route
path="/dashboard"
element={<Dashboard />}
/>
<Route
path="/profile"
element={<Profile />}
/>
</Route>
</Routes>
);
}
export default App;
Una ventaja de este enfoque es que cualquier ruta incluida dentro de ProtectedRoute queda protegida automáticamente.
Cuando aparecen veinte o treinta páginas privadas, la configuración sigue siendo sencilla.
Implementando el Login
Para este ejemplo basta con simular el inicio de sesión.
import { useNavigate } from "react-router";
import { useAuth } from "../auth/AuthContext";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
function handleLogin() {
login();
navigate("/dashboard");
}
return (
<div>
<h1>Login</h1>
<button
onClick={handleLogin}
>
Iniciar sesión
</button>
</div>
);
}
En un proyecto real, el botón ejecutaría una petición HTTP.
POST /api/login
Si las credenciales son válidas, el backend devolvería el token correspondiente.
Manteniendo la sesión tras recargar la página
Muchos desarrolladores se sorprenden cuando todo funciona correctamente hasta que el navegador se actualiza.
Después del refresh, el usuario vuelve a estar deslogueado.
Eso ocurre porque el estado de React vive únicamente en memoria.
Una alternativa sencilla consiste en guardar la sesión.
localStorage.setItem(
"user",
JSON.stringify(user)
);
Y recuperarla cuando la aplicación se inicia.
const storedUser = localStorage.getItem("user");
const [user, setUser] = useState(
storedUser
? JSON.parse(storedUser)
: null
);
Sin embargo, almacenar información sensible en localStorage tiene implicaciones de seguridad.
En aplicaciones donde la autenticación es crítica suele utilizarse:
- Cookies HttpOnly
- Refresh Tokens
- Renovación automática del Access Token
Ese enfoque reduce la exposición frente a ataques XSS.
Protegiendo rutas según el rol
No todos los usuarios deberían acceder al mismo contenido.
Un administrador puede gestionar usuarios.
Un cliente únicamente puede consultar sus pedidos.
La Protected Route puede ampliarse fácilmente.
import {
Navigate,
Outlet,
} from "react-router";
import { useAuth } from "./AuthContext";
export default function ProtectedRoute({
allowedRoles,
}) {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
if (!allowedRoles.includes(user.role)) {
return <Navigate to="/403" replace />;
}
return <Outlet />;
}
Y la configuración cambia muy poco.
<Route
element={
<ProtectedRoute
allowedRoles={["admin"]}
/>
}
>
<Route
path="/admin"
element={<AdminPanel />}
/>
</Route>
El resultado es bastante más limpio que llenar cada página con comprobaciones de permisos.
Recordando la página original
Un detalle que mejora bastante la experiencia consiste en devolver al usuario exactamente donde quería entrar después del login.
return (
<Navigate
to="/login"
replace
state={{
from: location,
}}
/>
);
Posteriormente:
const location = useLocation();
const from =
location.state?.from?.pathname ||
"/dashboard";
navigate(from);
Este comportamiento es especialmente útil cuando el usuario accede desde un marcador o un enlace compartido.
Validación desde el backend
Una Protected Route mejora la experiencia del usuario, pero nunca sustituye la validación del servidor.
Si alguien ejecuta directamente:
GET /api/admin/users
El backend debe verificar:
- autenticación
- permisos
- expiración del token
- roles
Nunca conviene confiar únicamente en el frontend para proteger información.
El navegador pertenece al usuario y cualquier validación implementada exclusivamente allí puede modificarse o eliminarse.
Separando autenticación y autorización
Es habitual mezclar ambos conceptos.
Autenticación responde a una pregunta:
¿Quién eres?
Autorización responde a otra:
¿Puedes acceder a este recurso?
Esa diferencia parece pequeña, pero simplifica bastante la arquitectura.
Por ejemplo:
Usuario autenticado
No implica necesariamente:
Usuario autorizado
Puede existir una sesión válida y, aun así, no disponer de permisos suficientes para acceder al panel administrativo.
Cuándo evitar múltiples Protected Routes
Otro error frecuente consiste en crear un componente distinto para cada página.
DashboardRoute
ProfileRoute
UsersRoute
OrdersRoute
BillingRoute
La mayoría terminan siendo idénticos.
Es preferible mantener un único componente configurable.
<ProtectedRoute
allowedRoles={[
"admin",
"editor"
]}
/>
La lógica permanece centralizada y cualquier modificación futura afecta a toda la aplicación.
Buenas prácticas
- Mantén la lógica de autenticación fuera de los componentes de página.
- Nunca ocultes únicamente el menú pensando que eso protege las rutas.
- Usa
Outletpara agrupar páginas privadas en lugar de repetir validaciones. - Redirige utilizando
replacepara evitar historiales inconsistentes. - Diferencia autenticación de autorización desde el principio.
- No almacenes información sensible del usuario en
localStoragesi el proyecto requiere un nivel alto de seguridad. - Valida siempre permisos y tokens desde el backend, incluso cuando el frontend ya haya restringido el acceso.
- Centraliza la gestión de roles para evitar condicionales repartidos por toda la aplicación.
- Conserva la ruta original para mejorar la experiencia después del inicio de sesión.
- Mantén
ProtectedRoutepequeño y enfocado. Cuando empieza a acumular lógica de negocio, suele ser una señal de que parte de esa responsabilidad debería trasladarse al contexto de autenticación o al servidor.