Navegadores Anidados
Stacks dentro de tabs dentro de stacks sin perder el comportamiento de atrás. Cada app/**/_layout.tsx declara un navegador; las rutas secundarias heredan ese shell. Expo Router mapea URLs a la pantalla anidada correcta - navegas con rutas completas como /orders/42, no con objetos { screen, params } de React Navigation.
Tarjeta de referencia rápida - lista para copiar y pegar.
app/
├── _layout.tsx # Root Stack
├── index.tsx
└── (app)/
├── _layout.tsx # Tabs (o NativeTabs)
└── (tabs)/
├── _layout.tsx # Tab bar
├── home/
│ ├── _layout.tsx # Stack por tab
│ ├── index.tsx # /home
│ └── feed.tsx # /home/feed
└── settings/
├── _layout.tsx
└── index.tsx
// app/_layout.tsx - root stack envuelve el tab group
import { Stack } from "expo-router" ;
export default function RootLayout () {
return (
< Stack screenOptions = {{ headerShown: false }}>
< Stack.Screen name = "(app)" />
< Stack.Screen
name = "compose"
options = {{ presentation: "modal" , title: "New post" }}
/>
</ Stack >
);
}
// app/(app)/(tabs)/_layout.tsx - tabs poseen cuatro stacks
import { Tabs } from "expo-router" ;
export default function TabLayout () {
return (
< Tabs >
< Tabs.Screen name = "home" options = {{ title: "Home" }} />
< Tabs.Screen name = "search" options = {{ title: "Search" }} />
< Tabs.Screen name = "inbox" options = {{ title: "Inbox" }} />
< Tabs.Screen name = "profile" options = {{ title: "Profile" }} />
</ Tabs >
);
}
// app/(app)/(tabs)/home/_layout.tsx - stack dentro de una tab
import { Stack } from "expo-router" ;
export default function HomeStackLayout () {
return (
< Stack >
< Stack.Screen name = "index" options = {{ title: "Home" }} />
< Stack.Screen name = "feed" options = {{ title: "Feed" }} />
< Stack.Screen name = "[id]" options = {{ title: "Post" }} />
</ Stack >
);
}
Cuándo usarlo:
Tabs + detail pushes - cada tab mantiene su propio back stack
Modal sobre tabs - root stack presenta compose sobre el tab shell
Grupo auth dentro de root stack - (auth) y (app) como stacks hermanos
Native tabs + headers - anida <Stack /> dentro de cada carpeta de native tab
Root stack - tabs - stacks por tab, con comportamiento de atrás predecible y targets de deep-link.
// app/_layout.tsx
import { Stack } from "expo-router" ;
import { SessionProvider } from "@/features/auth" ;
export default function RootLayout () {
return (
< SessionProvider >
< Stack >
< Stack.Screen name = "(app)" options = {{ headerShown: false }} />
< Stack.Screen name = "(auth)" options = {{ headerShown: false }} />
</ Stack >
</ SessionProvider >
);
}
// app/(app)/(tabs)/_layout.tsx
import { Tabs } from "expo-router" ;
export default function TabsLayout () {
return (
< Tabs screenOptions = {{ headerShown: false }}>
< Tabs.Screen name = "home" options = {{ tabBarLabel: "Home" }} />
< Tabs.Screen name = "inbox" options = {{ tabBarLabel: "Inbox" }} />
</ Tabs >
);
}
// app/(app)/(tabs)/inbox/_layout.tsx
import { Stack } from "expo-router" ;
export default function InboxStackLayout () {
return (
< Stack >
< Stack.Screen name = "index" options = {{ title: "Inbox" }} />
< Stack.Screen name = "[threadId]" options = {{ title: "Thread" }} />
</ Stack >
);
}
// app/(app)/(tabs)/inbox/index.tsx
import { Link, router } from "expo-router" ;
import { FlatList, Pressable, Text } from "react-native" ;
const threads = [{ id: "t1" , subject: "Hello" }, { id: "t2" , subject: "Ship it" }];
export default function InboxListScreen () {
return (
< FlatList
data = {threads}
keyExtractor = {( item ) => item.id}
renderItem = {({ item }) => (
< Pressable onPress = {() => router. push ( `/inbox/${ item . id }` )}>
< Text style = {{ padding: 16 }}>{item.subject}</ Text >
</ Pressable >
)}
/>
);
}
// app/(app)/(tabs)/inbox/[threadId].tsx
import { Link, useLocalSearchParams, useRouter } from "expo-router" ;
import { Button, Text, View } from "react-native" ;
export default function ThreadScreen () {
const { threadId } = useLocalSearchParams <{ threadId : string }>();
const router = useRouter ();
return (
< View style = {{ flex: 1 , padding: 16 }}>
< Text >Thread: {threadId}</ Text >
< Button title = "Back" onPress = {() => router. back ()} />
< Link href = "/compose" asChild >
< Button title = "Reply (modal)" />
</ Link >
</ View >
);
}
// Deep link - aterrizaje en thread dentro de inbox tab, atrás regresa a la lista de inbox
// myapp://inbox/t2
import { router } from "expo-router" ;
router. push ( "/inbox/t2" );
React Navigation anida navegadores; Expo Router expresa el mismo árbol a través de carpetas:
Usuario en /inbox/t2 presiona atrás en Android:
1. Inbox Stack saca t2 - /inbox (lista)
2. Usuario presiona atrás de nuevo - se queda en Inbox tab (raíz de tab no tiene nada que sacar)
3. Usuario cambia a Home tab - estado de Inbox stack se preserva (por defecto)
4. Usuario abre modal /compose - atrás descarta modal, tabs permanecen debajo
Controla el comportamiento de reset de tab con unmountOnBlur en Tabs.Screen cuando stacks obsoletos son inaceptables (ej. flujos de wizard).
Cuando usas Native Tabs , cada carpeta de tab obtiene su propio _layout.tsx exportando <Stack />:
// app/(app)/(native-tabs)/inbox/_layout.tsx
import { Stack } from "expo-router" ;
export default function InboxStack () {
return < Stack />;
}
Los disparadores de native tab no auto-sacan stacks anidados a menos que disablePopToTop sea false (por defecto en Android SDK 55+).
Deep-linking directamente a una ruta modal sin un ancla borra la pantalla detrás de ella. Exporta unstable_settings en el stack que posee el modal:
// app/(app)/_layout.tsx
export const unstable_settings = {
anchor: "(tabs)" ,
};
import { Stack } from "expo-router" ;
export default function AppLayout () {
return (
< Stack >
< Stack.Screen name = "(tabs)" />
< Stack.Screen name = "filters" options = {{ presentation: "modal" }} />
</ Stack >
);
}
Objetivo Expo Router Abre thread en inbox tab router.push("/inbox/t2")Cambia tab y reinicia su stack router.replace("/home")Abre modal sobre tabs router.push("/compose")Saca a raíz de tab router.dismissTo("/inbox") o re-tap de tab (native tabs)
Prefiere router.replace después de transiciones de auth para que los usuarios no puedan deslizar hacia atrás en pantallas de credenciales - ver ../expo-router/redirects-and-index-routes/redirects-and-index-routes.md .
Esperando un back stack global único - Cada tab mantiene su propio stack. Solución: Diseña URLs por tab; documenta qué flujos usan replace vs push.
Deep link modal sin ancla - Abrir /compose inicia en frío sin contexto de tab. Solución: Configura unstable_settings.anchor en el stack padre.
Duplicando pantallas entre navegadores - El mismo profile.tsx en dos carpetas de tab causa rutas ambiguas. Solución: Un archivo por URL; usa shared routes si es realmente necesario.
Retornar null desde root layout - Bloquea el renderizado estático y puede romper la hidratación anidada. Solución: Splash screen + session provider; renderiza <Stack /> una vez listo.
Empujando con rutas relativas entre tabs - ../settings desde /inbox/t2 puede no aterrizar donde esperas. Solución: Usa hrefs absolutos (/settings) con typed routes.
Anidando Drawer dentro de Tabs sin planificar - Conflictos de gesture y headers dobles. Solución: Drawer en raíz o tabs dentro de drawer - elige un patrón primario por app.
Alternativa Usalo Cuando No lo Uses Cuando Single root stack (sin tabs) Flujos lineales, onboarding wizards Cuatro+ destinos de nivel superior Tabs sin stacks por tab Contenido plano de tab, sin detail pushes Cada tab necesita list - detail Native tabs + stacks por tab Tab bar de plataforma + headers Necesitas tab UI completamente custom Un stack + segment-based UI Layout simple de dos paneles en tablet True back stacks independientes por sección React Native Modal component Alertas efímeras, no se necesita URL El flujo debe ser deep-linkeable o en historial
¿Por qué atrás en Android se queda en la tab en lugar de abandonar la app?
Atrás saca del navegador más interno primero.
En una pantalla raíz de tab, no hay nada izquierdo que sacar dentro de esa tab.
Usa BackHandler en verdaderos puntos de salida de app (ej. home tab root) si el producto requiere doble-atrás-para-salir.
¿Cómo reinicio el stack de una tab cuando la reselecciono?
JavaScript tabs: configura listeners de tab press vía opciones de React Navigation.
Native tabs: re-tap por defecto saca a raíz; usa disablePopToTop en NativeTabs.Trigger para mantener profundidad.
¿Puedo anidar un Drawer dentro de Tabs?
app/(app)/_layout.tsx - Drawer
app/(app)/(drawer)/_layout.tsx - Stack o hijo de Tabs
Sí - pero prefiere uno shell primario. El anidamiento profundo aumenta confusión de back-stack y tamaño de bundle.
¿Cómo funcionan las typed routes con anidamiento?
¿Deben los modales vivir en el root stack o dentro de un tab stack?
Root stack - compose global, filters, media picker (visible desde cualquier tab).
Tab stack - acciones de detalle limitadas a una sección (editar single inbox thread).
Los modales de raíz necesitan anchor para deep links.
¿Cómo pruebo la navegación anidada?
import { renderRouter, screen } from "expo-router/testing-library" ;
renderRouter ({
"inbox/index" : () => < InboxListScreen />,
"inbox/[threadId]" : () => < ThreadScreen />,
});
Afirma segmentos de pathname después de router.push - ver ../testing/react-native-testing-library/react-native-testing-library.md .
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).