Stop using manual redirects for Expo Router authentication - Use Protected Stack instead

9 septembre 2025 Read on Hashnode

Picture this: You're building a React Native app with authentication. Your PM walks over and says, "Hey, can you add an onboarding flow for first-time users? "

You think to yourself: "Great, more conditional routing logic scattered across my layouts."

Managing authentication and conditional redirects in Expo Router was traditionally a task that required layouts with manual redirects. With the arrival of Stack.Protected in SDK 53, Expo offers us a more elegant and declarative approach.

Let's see how this new feature simplifies conditional route management.

Reminder: How Expo Router Registers Routes

Expo Router uses the file system to automatically generate routes. Every file in the app/ folder becomes a route:

app/
├── _layout.tsx          # Root layout
├── login.tsx            # Route "/login"
├── (protected)
    ├── _layout.tsx          # Protected layout
    ├── index.tsx            # Route "/"
    └── profile.tsx          # Route "/profile"

Routes are defined in the _layout.tsx with Stack.Screen components that correspond to your files:

// app/_layout.tsx
export default function AppLayout() {
  return (
    <Stack>
      <Stack.Screen name="(protected)" />
      <Stack.Screen name="login" />
    </Stack>
  );
}

How We Used to Do It: Manual Redirects

Traditionally, to handle authentication, we used layouts with conditional redirects:

// app/(protected)/_layout.tsx
export default function RootLayout() {
  const { status } = useAuth();

  if (status === "unauthenticated") {
    return <Redirect href="/login" />;
  }

  return <Stack />;
}

This approach worked well, but we used redirects, and when we have different use cases like first launch or first login, it can be challenging.

The New Approach: Stack.Protected

With Stack.Protected, we can now declare directly which routes are accessible based on certain conditions:

// app/_layout.tsx
export default function RootLayout() {
  const { status } = useAuth();

  const isAuthed = status === "authenticated";
  const isNotAuthed = status === "unauthenticated";
  const isLoading = status === "loading";

  useEffect(() => {
    if (!isLoading) {
      SplashScreen.hideAsync();
    }
  }, [isLoading]);

  return (
    <Stack>
      <Stack.Protected guard={isNotAuthed}>
          <Stack.Screen name="login" />
      </Stack.Protected>

      {/* Private routes - only when authenticated */}
      <Stack.Protected guard={isAuthed}>
        <Stack.Screen name="index" />
        <Stack.Screen name="profile" />
        <Stack.Screen name="settings" />
      </Stack.Protected>
    </Stack>
  );
}

Practical Example: First-Time User Management

Here's a complete example of managing different app states:

export default function RootLayout() {
  const { user, isLoading } = useAuth();

  const isFirstTime = !user?.hasCompletedOnboarding;
  const isLoggedIn = !!user;

  useEffect(() => {
    if (!isLoading) {
      SplashScreen.hideAsync();
    }
  }, [isLoading]);

  return (
    <Stack screenOptions={{ headerShown: false }}>
      {/* Onboarding for new users */}
      <Stack.Protected guard={isFirstTime}>
        <Stack.Screen name="onboarding" />
        <Stack.Screen name="welcome" />
      </Stack.Protected>

      {/* Authentication screens */}
      <Stack.Protected guard={!isLoggedIn && !isFirstTime}>
        <Stack.Screen name="login" />
        <Stack.Screen name="register" />
        <Stack.Screen name="forgot-password" />
      </Stack.Protected>

      {/* Main application */}
      <Stack.Protected guard={isLoggedIn && !isFirstTime}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="profile" />
        <Stack.Screen name="settings" />
      </Stack.Protected>
    </Stack>
  );
}

Beyond Authentication: App States

Stack.Protected isn't limited to authentication. Here's an example with an Uber-like app:

export default function AppLayout() {
  const { rideStatus } = useRide();

  return (
    <Stack>
      {/* Normal screens when no active ride */}
      <Stack.Protected guard={rideStatus === 'idle'}>
        <Stack.Screen name="home" />
        <Stack.Screen name="history" />
        <Stack.Screen name="profile" />
        <Stack.Screen name="payment" />
      </Stack.Protected>

      {/* During ride - only map */}
      <Stack.Protected guard={rideStatus === 'in-ride'}>
        <Stack.Screen name="index" />
      </Stack.Protected>
    </Stack>
  );
}

In this example, depending on the ride state, different screens become available or inaccessible automatically. If a user is in an active ride and tries to access /profile, they'll be automatically redirected to /ride-map.

Automatic Behavior

Stack.Protected automatically handles redirects:

  • Blocked navigation: If you try to go to an inaccessible protected route, redirect to the first available screen

  • State change: If you're on a route that becomes inaccessible, automatic redirect

  • History cleanup: Navigation history of inaccessible routes is removed

Advantages of This Approach

Declarative and readable: You can immediately see which routes are available under which conditions.

Centralized: All protection logic is in the same place, in the layout.

Automatic: No need to manually handle redirects in each layout.

Flexible: Works for authentication, permissions, app states, etc.

Conclusion

Stack.Protected brings a more modern and declarative approach to handling conditional routes in Expo Router. Instead of managing scattered manual redirects, you can now clearly declare which routes are accessible under which conditions.

This evolution makes code more readable, maintainable, and reduces errors related to manual redirect management. It's an excellent example of how Expo continues to improve the developer experience.

By Lucas Rouret