I’m trying to implement a global <Redirector /> component in Expo Router to control the navigation flow of my app.
The logic depends on:
Authentication state
Whether the user completed onboarding
Whether the user passed the paywall
Some route groups (tabs), (settings), (capsule)
etc.
I attempted the solution below, and although it works most of the time, it occasionally causes unexpected navigation bugs. Fixing those bugs is time-consuming, so I’m wondering whether my approach is fundamentally wrong or if there’s a recommended pattern I should follow.
I’ve read the official Expo Router docs about redirects, but they’re not detailed enough for multi-layered logic like auth/onboarding/paywall and etc.
Here is my current implementation:
// Redirector.tsx
import { useAuth } from "@/hooks/useAuth";
import { getHasOnboarded, getHasPaywalled } from "@/utils/storage";
import {
Redirect,
usePathname,
useRootNavigationState,
useSegments,
} from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, useMemo, useState } from "react";
SplashScreen.preventAutoHideAsync().catch(() => {});
const Redirector = ({ children }: { children: React.ReactElement }) => {
const [hasOnboarded, setHasOnboarded] = useState<boolean | undefined | null>(
undefined
);
const [hasPaywalled, setHasPaywalled] = useState<boolean | undefined | null>(
undefined
);
const { auth, loading } = useAuth();
const pathname = usePathname();
const navState = useRootNavigationState();
const segments = useSegments();
useEffect(() => {
(async () => {
const hasO = await getHasOnboarded();
const hasP = await getHasPaywalled();
setHasOnboarded(hasO);
setHasPaywalled(hasP);
})();
}, []);
const ready = useMemo(
() =>
Boolean(navState?.key) &&
!loading &&
hasOnboarded !== undefined &&
hasPaywalled !== undefined,
[navState?.key, loading, hasOnboarded, hasPaywalled]
);
useEffect(() => {
if (ready) {
SplashScreen.hideAsync().catch(() => {});
}
}, [ready]);
const onOnboarding = pathname.startsWith("/onboarding");
const onPaywalling = pathname.startsWith("/paywall");
const onTabs = segments[0] === "(tabs)";
const onCapsule = segments[0] === "(capsule)";
const onSettings = segments[0] === "(settings)";
const onAuth =
pathname.startsWith("/register") ||
pathname.startsWith("/forgot-password") ||
pathname.startsWith("/login");
const onLogin = pathname.startsWith("/login");
if (!navState?.key || loading) {
return null;
}
if (!hasOnboarded && !onPaywalling) {
if (!onOnboarding) return <Redirect href="/onboarding" />;
return children;
}
if (!auth && hasOnboarded && !hasPaywalled) {
if (!onPaywalling) return <Redirect href={"/paywall"} />;
return children;
}
if (auth && !onPaywalling && !onCapsule && !onSettings) {
if (!onTabs) return <Redirect href={"/(tabs)"} />;
return children;
}
if (!auth && hasOnboarded && !onAuth && hasPaywalled) {
if (!onLogin) return <Redirect href="/login" />;
return children;
}
return children;
};
export default Redirector;
And I use it like this in RootLayout:
// _layout.tsx
...
<GlobalLayout>
<Redirector>
<Stack>
...
</Stack>
</Redirector>
</GlobalLayout>
...
My question
What is the correct or recommended way to implement global redirect logic (auth/onboarding/paywall) in Expo Router?
Is there a more reliable pattern or best practice for handling these flows without causing navigation inconsistencies?