andy.dev

Protected Routes con React Router

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 Outlet para agrupar páginas privadas en lugar de repetir validaciones.
  • Redirige utilizando replace para evitar historiales inconsistentes.
  • Diferencia autenticación de autorización desde el principio.
  • No almacenes información sensible del usuario en localStorage si 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 ProtectedRoute pequeñ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.