Skip to content
This page has been auto-translated and may contain errors.View in English

Rutas protegidas

Algunas partes de una app solo deberían ser visibles para un usuario conectado: un panel de control, una página de cuenta, cualquier cosa que obtenga datos personales. React Router no tiene una característica dedicada de "ruta protegida", y tampoco la necesita. Los componentes de enrutamiento y rutas anidadas se combinan en un patrón: una ruta layout que verifica si el usuario está conectado y renderiza sus hijos o redirige a la página de inicio de sesión.

Una cosa que hay que aclarar antes de cualquier código. Proteger rutas en el cliente es una característica de experiencia del usuario. Mantiene a los visitantes desconectados fuera de páginas que obtendrían sus datos y mostrarían pantallas vacías y rotas. Nunca es seguridad, porque todo en un bundle de cliente es inspectable y evitable.

La verdadera protección sucede en el servidor, que debe rehusar entregar datos a solicitudes que no estén autenticadas. Autenticación cubre cómo funciona esa protección; este capítulo trata sobre la mitad del cliente.

Una ruta layout que requiere autenticación

Una ruta layout es una ruta padre cuyo elemento renderiza UI compartida más un Outlet para sus hijos. Una ruta layout sin ruta no agrega ningún segmento de URL; existe puramente para envolver. Eso la hace el lugar perfecto para una validación de inicio de sesión: envuelve cada ruta que quieras proteger en una ruta layout sin ruta cuyo único trabajo es decidir si el Outlet se renderiza.

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>

Todo anidado dentro de <Route element={<AuthRequired />}> ahora está protegido, incluyendo rutas con parámetros como host/vans/:id. Proteger una nueva página después es una línea: mueve su ruta dentro del envoltorio.

El guardián tiene dos ramas. Si el usuario está conectado, renderiza el Outlet para que aparezca el hijo que coincide. Si no lo está, renderiza algo que lo envíe a la página de inicio de sesión en su lugar. No renderizar nada es el truco completo: si un componente protegido nunca se renderiza, ninguna obtención de datos dentro de él se inicia.

jsx
import { Outlet, Navigate } from 'react-router-dom'

export default function AuthRequired() {
  const authenticated = false // marcador de posición para una validación de sesión real

  if (!authenticated) {
    return <Navigate to="/login" />
  }
  return <Outlet />
}

El curso simula la validación con un booleano, y después con un valor en localStorage, para mantener el enfoque en el enrutamiento. Una validación real lee el estado de la sesión, a menudo entregada a través de context; nuevamente, Autenticación cubre de dónde viene ese estado realmente.

Nota de versión

Los ejemplos aquí importan de react-router-dom, que funciona tanto en v6 como v7; Enrutamiento cubre qué cambió v7 sobre los paquetes. El curso enseña v6 a lo largo de esta sección.

El componente Navigate

Link renderiza un ancla y espera ser cliqueado. Navigate se salta la espera: en cuanto se renderiza, el router mueve al usuario a su ruta to. Eso lo hace la herramienta de redirección para lógica de renderizado.

Tu componente decide, a mitad del renderizado, que el usuario no debería estar aquí, y retorna <Navigate to="/login" /> en lugar de cualquier UI. También hay un hook useNavigate que retorna una función para redirigir desde manejadores de eventos, que es cómo el formulario de inicio de sesión mismo envía al usuario hacia adelante después de un envío exitoso.

Llegar a una página de inicio de sesión desnuda sin explicación es confuso, así que el guardián debería decir por qué el usuario terminó allí. Navigate toma el mismo prop state que Link hace, así que una redirección puede entregar al destino una razón.

jsx
if (!authenticated) {
  return (
    <Navigate
      to="/login"
      state={{ message: 'Debes iniciar sesión primero' }}
    />
  )
}
jsx
import { useLocation } from 'react-router-dom'

function Login() {
  const location = useLocation()

  return (
    <>
      {location.state?.message && <h3>{location.state.message}</h3>}
      <h1>Inicia sesión en tu cuenta</h1>
      {/* formulario... */}
    </>
  )
}

El encadenamiento opcional importa, porque alguien que hace clic directamente en la página de inicio de sesión nunca pasó por el guardián y encuentra location.state configurado en null.

El historial y reemplazar

El patrón hasta ahora tiene un bug que puedes sentir. Visita una página protegida mientras estás desconectado, obtén una redirección, inicia sesión, luego presiona el botón Atrás del navegador. En lugar de la página en la que estabas antes de todo esto, aterrizas en la página de inicio de sesión nuevamente, completa con su mensaje "debes iniciar sesión primero".

El navegador mantiene un historial: cada navegación empuja una nueva entrada, y Atrás vuelve a la anterior. La redirección empujó /login al historial justo después de la página protegida, y la propia navegación del formulario de inicio de sesión empujó la página protegida encima de eso. Atrás te camina directamente a los restos.

La solución es hacer que las redirecciones reemplacen la entrada actual en lugar de empujar una nueva. En el componente Navigate eso es el prop replace; en la función useNavigate es una opción.

jsx
// en AuthRequired: la redirección se reemplaza a sí misma por la página bloqueada
return <Navigate to="/login" state={{ message: 'Debes iniciar sesión primero' }} replace />

// en Login, después de un envío exitoso
navigate('/host', { replace: true })

Con ambos en su lugar, el desvío de inicio de sesión nunca sobrevive en el historial. Atrás desde la página protegida ahora va a donde el usuario estaba antes, y la página de inicio de sesión no puede revisitarse por accidente. Una buena regla general: cualquier navegación que el usuario no pidió, que es lo que es una redirección, debería reemplazar.

De vuelta a donde se dirigían

Un rough edge aún permanece. El formulario de inicio de sesión codifica navigate('/host', ...), así que un usuario que intentaba alcanzar /host/vans/2 obtiene tirado a /host después de iniciar sesión. El guardián conoce la URL bloqueada, porque useLocation dentro de AuthRequired describe la página que el usuario intentaba renderizar. Pásalo en el mismo estado de navegación.

jsx
export default function AuthRequired() {
  const location = useLocation()
  const authenticated = false

  if (!authenticated) {
    return (
      <Navigate
        to="/login"
        state={{
          message: 'Debes iniciar sesión primero',
          from: location.pathname + location.search + location.hash,
        }}
        replace
      />
    )
  }
  return <Outlet />
}

pathname solo no es la dirección completa: un usuario bloqueado en /host/vans?type=luxury se dirigía a la lista filtrada, y dejar la cadena de consulta quita el filtro. Agregar search y hash reproduce cada parte de la URL que el router rastrea. La página de inicio de sesión lee el valor nuevamente, con un fallback para personas que vinieron a la página de inicio de sesión directamente y por lo tanto no tienen estado.

jsx
function Login() {
  const location = useLocation()
  const navigate = useNavigate()
  const from = location.state?.from || '/host'

  function handleLogin() {
    // ...al completarse:
    navigate(from, { replace: true })
  }
  // ...
}

Ahora un enlace compartido a cualquier página protegida sobrevive al desvío de inicio de sesión: bloqueado, redirigido, conectado, y entregado a la URL exacta que querían.

El marcador de posición const authenticated = false oculta el primer bug en el que se topa este patrón en una app real. El estado de la sesión se resuelve asincronamente: en el primer renderizado, la app ha preguntado al proveedor de autenticación si existe una sesión y aún no ha escuchado una respuesta. Un guardián de dos vías lee ese silencio como "desconectado" y redirige a un usuario que está conectado, así que un refresco en una página protegida lo envía a la pantalla de inicio de sesión por un momento antes de que la app se corrija. Modela tres estados en su lugar, que es la misma forma que Autenticación construye en el lado del proveedor.

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

export default function AuthRequired() {
  const { session } = useAuth() // undefined mientras se resuelve, null cuando está desconectado
  const location = useLocation()

  if (session === undefined) {
    return <p>Verificando tu sesión...</p>
  }
  if (session === null) {
    return (
      <Navigate
        to="/login"
        state={{
          message: 'Debes iniciar sesión primero',
          from: location.pathname + location.search + location.hash,
        }}
        replace
      />
    )
  }
  return <Outlet />
}

useAuth es un hook personalizado sobre un proveedor de contexto, el patrón de context: el proveedor posee el estado de la sesión y cada componente lee la misma respuesta. Los tres valores son el punto completo. undefined significa que la verificación aún está en vuelo, null significa que volvió vacío, y un objeto de sesión significa que alguien está conectado.

La rama de carga es la que la gente deja fuera, y es la diferencia entre un guardián que sobrevive a un refresco y uno que rebota usuarios conectados. Mantén esa rama ligera, ya que se renderiza en la primera pintura de cada página protegida, y dale suficiente presencia para que la pantalla no se vea rota mientras se ejecuta la verificación.

Navigate es una redirección declarativa: renderizarla es la instrucción. Bajo el capó, llama la función navigate del router desde un efecto después de que el renderizado se compromeта, porque navegar es un efecto secundario y un componente no puede causar uno mientras se está renderizando. Retornarlo temprano también cortocircuita todo el subárbol, y ese es el verdadero beneficio de proteger a nivel de layout: componentes que nunca montan nunca ejecutan sus efectos, así que sus obtenciones nunca se disparan. El guardián se sienta en un único punto de estrangulamiento en lugar de estar esparcido en cada componente protegido como una verificación por página.

El estado de navegación viaja en la entrada de historial del navegador, con todas las consecuencias que route params atraviesa. replace se asigna directamente a la semántica history.replaceState: la entrada se sobrescribe, así que la redirección no deja rastro para caminar hacia Atrás.

Un guardián escrito como un componente tiene que renderizar antes de que pueda decidir cualquier cosa, así que una sesión sin resolver compra un estado de carga en el mejor de los casos. Las APIs de datos de React Router mueven la decisión más temprano: un loader se ejecuta antes de que el elemento de la ruta se renderice, y lanzar un redirect desde él significa que el componente protegido nunca se crea en primer lugar.

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
}

Esa versión lleva el origen en la cadena de consulta, y el movimiento trae una consecuencia que hay que atrapar antes de que se lance. El estado del historial es escrito por tu propio guardián; una cadena de consulta es ingresada por quienquiera que envíe el enlace.

Valida el valor from antes de navegar

/login?from=https://phish.example/host es una URL válida que cualquiera puede publicar, y un manejador de inicio de sesión que lee from y lo entrega a navigate entregará al usuario fuera del sitio el instante en que se autentique, habiendo visto que escribió una contraseña en tu página de inicio de sesión real. Acepta el valor solo cuando comience con un único /, que rechaza tanto URLs absolutas como la forma relativa al protocolo //host que los navegadores tratan como fuera del sitio.

jsx
const raw = searchParams.get('from') || ''
const from = /^\/(?!\/)/.test(raw) ? raw : '/host'
JunoUn guardián para toda la rama En lugar de verificar un inicio de sesión dentro de cada página privada, envuelves todas esas rutas en un componente guardián. Si el usuario está conectado, renderiza Outlet y las páginas se muestran normalmente. Si no lo está, renderiza Navigate, que lo lleva a la página de inicio de sesión en el momento en que aparece.

Agregar replace mantiene la redirección fuera de la memoria del botón Atrás, y pasar a dónde se dirigían permite que la página de inicio de sesión los devuelva allí después.

Recuerda que esto es amabilidad para el usuario; el servidor aún tiene que proteger los datos por sí mismo.

JunoUn guardián para toda la rama Envuelve rutas protegidas en una ruta layout sin ruta cuyo elemento lee la sesión de un hook useAuth y se ramifica tres formas: una línea de carga mientras la sesión es undefined, Navigate to="/login" cuando es null, y Outlet una vez que un objeto de sesión llega. Dejar fuera esa primera rama es lo que rebota usuarios conectados a la página de inicio de sesión después de un refresco.

Dale a la redirección un objeto state que lleve un mensaje más location.pathname + location.search + location.hash como from, márcalo replace, y en el manejador de inicio de sesión llama navigate(from, { replace: true }) con un fallback sensato. Mantén el servidor haciendo cumplir el acceso en cada solicitud sin importar qué.

JunoUn guardián para toda la rama Proteger a nivel de layout cortocircuita el subárbol antes de que se monte, así que componentes protegidos nunca se renderizar y sus obtenciones nunca comienzan. El estado de navegación vive en la propia entrada del historial, y replace es history.replaceState disfrazado de router: las redirecciones siempre deberían reemplazar para que el desvío no deje entrada atrás.

Un guardián de componente aún tiene que renderizar una vez antes de que pueda decidir, así que en configuraciones de data-router la verificación se mueve a un loader que lanza redirect antes de que el elemento exista. Una vez que la URL de origen viaja en una cadena de consulta es suministrada por atacantes, así que valídala contra una barra inclinada única inicial antes de navegar a ella.

Nada de esto es protección; el servidor es dueño de eso.

Próximo: Cómo React renderiza, el modelo mental detrás de cada decisión de rendimiento que sigue.