Rutas Protegidas y Middleware
Puertas de autenticación, comprobaciones de rol y prevención de bucles de redirección. Expo Router SDK 57 introduce Stack.Protected y Tabs.Protected con booleans guard - el patrón recomendado sobre dispersar <Redirect /> en cada layout. La hidratación de sesión sigue viviendo en React context respaldado por almacenamiento ../auth-session/ ; las rutas protegidas reaccionan cuando guard cambia.
Tarjeta de referencia rápida - lista para copiar y pegar.
app/
├── _layout.tsx # SessionProvider + Stack.Protected
├── sign-in.tsx # guard={!session}
└── (app)/
├── _layout.tsx # authenticated stack
└── (tabs)/...
// app/_layout.tsx
import { Stack } from "expo-router" ;
import { SplashScreen } from "expo-router" ;
import { SessionProvider, useSession } from "@/features/auth" ;
SplashScreen. preventAutoHideAsync ();
export default function RootLayout () {
return (
< SessionProvider >
< SplashScreenController />
< RootNavigator />
</ SessionProvider >
);
}
function SplashScreenController () {
const { isLoading } = useSession ();
if ( ! isLoading) SplashScreen. hideAsync ();
return null ;
}
function RootNavigator () {
const { session } = useSession ();
return (
< Stack >
< Stack.Protected guard = { !! session}>
< Stack.Screen name = "(app)" />
</ Stack.Protected >
< Stack.Protected guard = { ! session}>
< Stack.Screen name = "sign-in" />
</ Stack.Protected >
</ Stack >
);
}
// app/sign-in.tsx
import { router } from "expo-router" ;
import { Button, View } from "react-native" ;
import { useSession } from "@/features/auth" ;
export default function SignInScreen () {
const { signIn } = useSession ();
return (
< View style = {{ flex: 1 , justifyContent: "center" , alignItems: "center" }}>
< Button
title = "Iniciar sesión"
onPress = { async () => {
await signIn ();
router. replace ( "/(app)/(tabs)" );
}}
/>
</ View >
);
}
Cuándo usarlo:
Autenticación binaria - conjuntos de rutas de usuario conectado vs invitado
Puertas de rol - Stack.Protected anidados para pantallas solo-admin
Visibilidad de tabs - oculta la pestaña profile hasta que esté autenticado
Expiración de sesión - guard cambia a false - usuario redirigido fuera de pantallas protegidas automáticamente
Proveedor de sesión, protección de rol anidado, fallback del hub de redirección y patrones seguros contra bucles.
// features/auth/model/SessionProvider.tsx
import { createContext, use, useEffect, useState, type PropsWithChildren } from "react" ;
import * as SecureStore from "expo-secure-store" ;
type Session = { userId : string ; role : "user" | "admin" } | null ;
const AuthContext = createContext <{
session : Session ;
isLoading : boolean ;
signIn : () => Promise < void >;
signOut : () => Promise < void >;
} | null >( null );
export function useSession () {
const ctx = use (AuthContext);
if ( ! ctx) throw new Error ( "useSession requires SessionProvider" );
return ctx;
}
export function SessionProvider ({ children } : PropsWithChildren ) {
const [ session , setSession ] = useState < Session >( null );
const [ isLoading , setLoading ] = useState ( true );
useEffect (() => {
SecureStore. getItemAsync ( "session" )
. then (( raw ) => setSession (raw ? JSON . parse (raw) : null ))
. finally (() => setLoading ( false ));
}, []);
const signIn = async () => {
const next : Session = { userId: "u1" , role: "user" };
await SecureStore. setItemAsync ( "session" , JSON . stringify (next));
setSession (next);
};
const signOut = async () => {
await SecureStore. deleteItemAsync ( "session" );
setSession ( null );
};
return (
< AuthContext value = {{ session, isLoading, signIn, signOut }}>
{children}
</ AuthContext >
);
}
// app/_layout.tsx - root guards
import { Stack } from "expo-router" ;
import { SessionProvider, useSession } from "@/features/auth" ;
function RootNavigator () {
const { session , isLoading } = useSession ();
if (isLoading) return null ;
const isLoggedIn = !! session;
const isAdmin = session?.role === "admin" ;
return (
< Stack >
< Stack.Protected guard = {isLoggedIn}>
< Stack.Protected guard = {isAdmin}>
< Stack.Screen name = "admin" />
</ Stack.Protected >
< Stack.Screen name = "(app)" />
</ Stack.Protected >
< Stack.Protected guard = { ! isLoggedIn}>
< Stack.Screen name = "sign-in" />
</ Stack.Protected >
</ Stack >
);
}
export default function RootLayout () {
return (
< SessionProvider >
< RootNavigator />
</ SessionProvider >
);
}
// app/index.tsx - optional redirect hub (avoid fighting Stack.Protected)
import { Redirect } from "expo-router" ;
import { useSession } from "@/features/auth" ;
export default function Index () {
const { session , isLoading } = useSession ();
if (isLoading) return null ;
if (session) return < Redirect href = "/(app)/(tabs)" />;
return < Redirect href = "/sign-in" />;
}
// app/(app)/(tabs)/_layout.tsx - protect individual tabs
import { Tabs } from "expo-router" ;
import { useSession } from "@/features/auth" ;
export default function TabsLayout () {
const { session } = useSession ();
const isLoggedIn = !! session;
return (
< Tabs >
< Tabs.Screen name = "home" options = {{ title: "Inicio" }} />
< Tabs.Protected guard = {isLoggedIn}>
< Tabs.Screen name = "inbox" options = {{ title: "Bandeja de entrada" }} />
< Tabs.Screen name = "profile" options = {{ title: "Perfil" }} />
</ Tabs.Protected >
</ Tabs >
);
}
Cuando guard es false:
El usuario no puede navegar a pantallas protegidas (Link, router.push).
Si ya está en una pantalla protegida cuando guard cambia a false, el router redirige a la ruta anclada (normalmente index) o la primera pantalla disponible en la pila.
Las entradas de historial de ese subárbol protegido se eliminan cuando guard cambia a false.
Coloca la pantalla de sign-in fuera del grupo autenticado Stack.Protected para que los invitados siempre tengan un destino de fallback.
Anti-patrón Bucle Solución index redirige a /sign-in mientras sign-in redirige a /Rebote infinito Usa Stack.Protected - solo una autoridad decide el fallback Tanto (auth) como (app) poseen index.tsx en / / ambiguoHub único app/index.tsx Redirige antes de isLoading === false Parpadeo sign-in - inicio - sign-in return null hasta que se complete la hidrataciónsign-in dentro del grupo autenticadoSin fallback cuando está desconectado Grupo guard={!session} para pantallas de autenticación router.push después del loginVolver a sign-in router.replace
// ❌ Riesgo de bucle - dos layouts ambos redirigen en /
// (auth)/_layout.tsx
if ( ! session) return < Redirect href = "/sign-in" />;
// (app)/_layout.tsx
if ( ! session) return < Redirect href = "/sign-in" />;
// ✅ Fuente única de verdad
// app/_layout.tsx - Stack.Protected solamente
Expo Router no envía middleware de rutas al estilo de Next.js en nativo. En web, el enrutamiento es principalmente generación estática hoy - sin middleware de servidor personalizado para redirects de autenticación por solicitud.
Capa Nativo Web (SDK 57) Stack.Protected✅ Guard de cliente ✅ Guard de cliente <Redirect /> en layouts✅ ✅ Middleware del servidor ❌ ❌ (aún no) Autenticación de ruta API Preocupación separada app/api/** + tu servidor
Trata las rutas protegidas como puertas de UX , no límites de seguridad. Valida tokens en cada llamada API - ver ../auth-session/mobile-auth-basics/mobile-auth-basics.md y ../auth-session/securestore-and-keychain-keystore/securestore-and-keychain-keystore.md .
Un usuario abriendo myapp://inbox/42 mientras está desconectado llega a una pantalla protegida - router redirige a la primera pantalla no protegida disponible (sign-in). Después del login, router.replace al href previsto:
const redirect = useGlobalSearchParams <{ redirect ?: string }>();
await signIn ();
router. replace ((redirect as `/${ string }` ) ?? "/(app)/(tabs)" );
Pasa el parámetro de consulta redirect al enviar usuarios a sign-in.
Llamar a signOut() cambia guard a false. El historial protegido se borra automáticamente - el usuario debería aterrizar en sign-in sin router.replace manual si los layouts están conectados correctamente.
Rutas protegidas ≠ seguridad del servidor - Las URLs aún están en el bundle. Solución: Autorización API, expiración de token, certificate pinning para aplicaciones sensibles.
Duplicar pantallas en grupos Protected - La misma profile en dos grupos lanza error. Solución: Una declaración; anida guards para roles.
Renderizar navigadores antes de hidratación - Un breve guard falso causa parpadeo de redirección. Solución: SplashScreen + puerta isLoading.
Usar disabled en tabs nativos como autenticación - disabled solo bloquea taps en la barra de tabs; router.push aún funciona. Solución: Tabs.Protected o Stack.Protected en raíz.
Exportación web estática de rutas protegidas - Las pantallas protegidas se saltan en tiempo de compilación; el acceso de URL directo puede recuperar JS. Solución: No confíes en SSG para secreto.
Modal de sign-in sin anclaje - Contexto de deep link perdido. Solución: unstable_settings.anchor - ver Modal & Presentation Routes .
Alternativa Usa cuando No uses cuando Stack.ProtectedSDK 57+ auth shells Debes soportar Expo Router < 5 <Redirect /> en layoutsHub simple, código legacy Matriz de rol compleja (prefiere Protected anidado) Solo router.replace imperativo Post-login único Cumplimiento de sesión continuo Guards a nivel de features en pantallas Acción extra sensible (eliminar cuenta) Reemplazando completamente autenticación a nivel de layout Sesión del servidor (rutas API web) Refresh de token, RBAC en servidor Esperando que bloquee navegación nativa solo
Stack.Protected vs Redirect - cuál gana?
Prefiere Stack.Protected para acceso a nivel de navegador en SDK 57+.
Usa <Redirect /> en app/index.tsx como hub de destino, no en cada layout anidado.
No combines reglas conflictivas en la misma ruta.
Cómo se componen los guards de rol anidados?
< Stack.Protected guard = {isLoggedIn}>
< Stack.Protected guard = {isAdmin}>
< Stack.Screen name = "admin" />
</ Stack.Protected >
< Stack.Screen name = "(app)" />
</ Stack.Protected >
/admin requiere ambos guards verdaderos. /about bajo el grupo externo requiere solo login.
Qué pasa cuando guard cambia mientras el usuario está en una pantalla protegida?
Router redirige al anclaje o primera pantalla disponible.
El historial de pantalla protegida se elimina de la pila.
Sin router.replace('/sign-in') manual requerida si sign-in es el fallback no protegido.
¿Puedo usar expo-auth-session con rutas protegidas?
Cómo prevengo el bucle sign-in ↔ home?
Espera a isLoading === false antes de cualquier redirección.
Autoridad única de guard en Stack.Protected en raíz.
router.replace después del login, nunca push.
Un app/index.tsx - sin rutas index compitiendo en grupos.
¿Hay middleware de Expo Router como Next.js?
No en nativo - usa guards de cliente.
Web - los redirects de middleware del servidor al estilo del servidor no se soportan aún; usa Stack.Protected de cliente y estados de carga.
Las rutas API se autentican separadamente en el servidor.
Versiones de Stack: Esta página fue escrita para React 19.2.3 , React Native 0.86.0 , y Expo SDK 57 (expo ~57.0.4).