The Authentication Flow
Guarding route groups with Stack.Protected, and holding the splash screen until the session has loaded so no wrong screen flashes.
<Stack.Protected guard={boolean}><Stack.Screen name="(app)" /></Stack.Protected>An authenticated app has two sets of screens: what a signed-out visitor may see, and what a session unlocks. Expo Router expresses that in the layout, once, rather than with a check inside every screen.
Protected groups
Since Expo Router v5 (Expo SDK 53) both Stack and Tabs expose a Protected component with a guard prop. Read guard as permission rather than as a lock: guard={true} means the screens inside are reachable, guard={false} means they are not, and a user standing on one when it flips is redirected to the anchor route. That flip also drops the group's entries from the navigation history, so the back gesture cannot return to a screen the user just lost.
Because the layout reacts to the session, signing in needs no navigation call. Write the session; the guard changes and the router moves.
guard={!!session} is shorthand for guard={Boolean(session)}. Identical values, and the double negation is what you will meet in other people's layouts.
The flash of the wrong screen
Reading a stored session is asynchronous. On the first render there is no session, so a naive guard is false, the router shows sign-in, the read finishes and it swaps back. The user sees the sign-in screen for a frame or two. Track loading as its own value, separate from the session, and keep the splash screen visible until the read resolves. Do not paper over it with a timeout.
Example
// app/_layout.tsx
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { SessionProvider, useSession } from "../ctx";
// Stop Expo hiding the splash screen before we know who the user is.
SplashScreen.preventAutoHideAsync();
function RootNavigator() {
const { session, isLoading } = useSession();
// Declared before any early return, so hook order is the same every render.
useEffect(() => {
if (!isLoading) {
SplashScreen.hideAsync();
}
}, [isLoading]);
// Render nothing while the stored session is being read; the splash covers it.
if (isLoading) {
return null;
}
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" options={{ title: "Sign in" }} />
</Stack.Protected>
</Stack>
);
}
export default function RootLayout() {
return (
<SessionProvider>
<RootNavigator />
</SessionProvider>
);
}When to use it
- Keep the whole app behind sign-in, with only a sign-in screen reachable without a session.
- Hide an account tab from visitors while leaving the rest of the app public.
- Send a user who has just signed up through onboarding before the main app.
- Drop the user back to sign-in when a token is rejected, without a back route into the old screens.
More examples
A sign-in screen that does not navigate
A centred title with a Sign in button beneath it; tapping it replaces the whole screen with the signed-in stack. The point is the absence of navigation code. A router.replace here on top of the guard change causes a visible double transition, and it is the first thing to delete when sign-in looks janky.
// app/sign-in.tsx
import { Button, StyleSheet, Text, View } from "react-native";
import { useSession } from "../ctx";
export default function SignIn() {
const { signIn } = useSession();
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome back</Text>
{/* No router.replace. Writing the session flips the guard and the router moves. */}
<Button title="Sign in" onPress={() => signIn("token-from-your-api")} />
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: "center", flex: 1, gap: 16, justifyContent: "center" },
title: { fontSize: 22, fontWeight: "600" },
});Hiding one tab rather than a whole group
Two tabs when signed in, one when not: the Account tab is absent from the bar rather than present and disabled. Reach for Tabs.Protected in apps where most content is public and only part of it needs an account, which is the common shape for a shop or a news reader.
// app/(app)/_layout.tsx
import { Tabs } from "expo-router";
import { useSession } from "../../ctx";
export default function AppTabs() {
const { session } = useSession();
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: "Feed" }} />
<Tabs.Protected guard={!!session}>
<Tabs.Screen name="account" options={{ title: "Account" }} />
</Tabs.Protected>
</Tabs>
);
}A second condition the guard cannot express
This assumes the provider's session object carries an onboarded flag alongside the token. Redirect is the longhand that Stack.Protected replaced for the signed-in/signed-out case, and it is still the right tool for a condition inside the authenticated area: incomplete onboarding, an expired subscription, a required terms acceptance. It renders instead of a screen, so put it in a layout where the data it depends on is already loaded.
// app/(app)/_layout.tsx
import { Redirect, Stack } from "expo-router";
import { useSession } from "../../ctx";
export default function AppLayout() {
const { session } = useSession();
// Signed in, but has not finished onboarding yet.
if (session && !session.onboarded) {
return <Redirect href="/onboarding" />;
}
return <Stack />;
}
Discussion