Twenty route files in our Expo app, three places that check for a login, and the deep link that is why
Munchable is an Expo app, and its entire navigation is 20 files under app/. Twelve of them are one group, one is the sign-in screen, and seven sit at the root. Here is the whole map: app/_layout.tsx root st
Munchable is an Expo app, and its entire navigation is 20 files under app/. Twelve of them are one group, one is the sign-in screen, and seven sit at the root. Here is the whole map:
app/_layout.tsx root stack, fonts, splash, one dialog host
app/index.tsx where a cold start decides what you see
app/onboarding.tsx
app/(auth)/sign-in.tsx
app/(app)/_layout.tsx the signed-in group
app/(app)/home.tsx hub
app/(app)/{profile,conditions,allergies,healthy,history,search}.tsx
app/(app)/recipes/{index,[slug]}.tsx
app/(app)/support/{index,[ticketId]}.tsx
app/capture.tsx sheet
app/menu.tsx sheet
app/paywall.tsx sheet
app/result.tsx card
Hub and spoke, no tab bar. What I want to write about is the login check, because it is written three times, and the reason is the sort of thing that is obvious in hindsight and invisible in review.
The gate that looks like it is enough
The group has a layout, and the layout redirects:
export default function AppLayout() {
const account = useAuth((s) => s.account);
const restored = useAuth((s) => s.restored);
if (REQUIRE_ACCOUNT && !restored) return null;
if (REQUIRE_ACCOUNT && !account) return <Redirect href="/(auth)/sign-in" />;
return <Stack screenOptions={{ headerShown: false, animation: 'slide_from_right' }}>{/* ... */}</Stack>;
}
Every screen in (app) renders under that layout, so every screen in (app) is gated. That is eleven screens covered by four lines, and it is genuinely the right place for it.
The trap is the word "every". A layout only wraps its own subtree. The four screens at the root of app/ are not in that subtree, and they are the four that matter most: the camera flow, the menu reader, the paywall and the product page.
Why routes outside the group are reachable at all
In a tab-bar app you would never notice, because the only way to reach a screen is to tap something that is already behind the gate. Expo Router is a URL router on every platform:
- the app registers a
munchable://scheme, so a link can open any route directly - the web build serves the same route tree, so anybody can type the path
- a cold start restoring a deep link lands on that route as the first route in the stack
None of those three paths go through app/index.tsx, and none of them render (app)/_layout.tsx. A signed-out visitor following a link to the product page gets the product page.
So the check is also a hook, used at the top of each of the four root screens:
const gate = useAccountGate();
if (gate) return gate;
export function useAccountGate(): React.ReactElement | null {
const account = useAuth((s) => s.account);
const restored = useAuth((s) => s.restored);
if (!REQUIRE_ACCOUNT) return null;
if (!restored) return <></>;
if (!account) return <Redirect href="/(auth)/sign-in" />;
return null;
}
Three states, not two, and the middle one is the whole reason this is a hook rather than a line of JSX. A persisted session is read from storage asynchronously, so for the first frames after launch "not signed in" and "we do not know yet" look identical. Returning an empty element while restored is false is what stops the sign-in screen flashing up in front of somebody who has been signed in for months.
The call order matters too: useAccountGate() goes after the screen's other hooks, because an early return before a useEffect is a hooks-order violation waiting for the first signed-out visitor to find it.
Fail closed, in the one place that decides
export const REQUIRE_ACCOUNT =
isSupabaseConfigured || process.env.EXPO_PUBLIC_ENV === 'production';
Local development without backend keys stays open, because requiring an account you cannot create is a bad afternoon. The second clause is the important one: a production build with missing keys is a mis-build, and a mis-built app has to fail closed to the sign-in screen rather than silently open the whole app to anybody. Writing it as isSupabaseConfigured alone would have made a dropped environment variable into an open door.
The third place: where a cold start goes
app/index.tsx is not a screen. It is a routing decision, four lines of it:
if (!onboarded) return <Redirect href="/onboarding" />;
if (REQUIRE_ACCOUNT && !restored) return null;
if (REQUIRE_ACCOUNT && !account) return <Redirect href="/(auth)/sign-in" />;
return <Redirect href="/home" />;
The ordering encodes a product decision that took a while to settle. Setting up who you are happens first and entirely on the device, before any account exists, because nobody wants to sign up to find out whether an app is for them. The account is required at the point of the first scan, not at the point of first launch.
Sheets, cards and one option that is not decoration
The root layout declares the presentation for each root screen:
const SHEET = { presentation: 'modal', animation: 'slide_from_bottom' } as const;
presentation: 'modal' would be enough on iOS. The animation is named as well because the stack's default sideways animation would otherwise apply to sheets too, and because the web build reads that name to pick its own direction of travel. Capture, menu and paywall are sheets: they are tasks you dismiss. The product page is a card: it is a place you go, so it slides in from the right like the rest of the app and swipes back from the edge.
One more detail that saves a crash report: back navigation goes through a helper rather than router.back(), because a screen reached by a deep link has nothing to pop.
if (router.canGoBack()) router.back();
else router.replace(fallback);
A raw back() there dispatches an unhandled GO_BACK, which is a thrown error in development and a dead button in production.
Declarations are options, not registration
The group's layout names eight <Stack.Screen> entries, and the group has eleven screens. The three it does not name work perfectly well. In Expo Router the file is the route; a <Stack.Screen> exists only to pin options or ordering, and a screen that wants the group defaults does not need an entry. Worth knowing before you go looking for the bug in why a screen "is not registered".
Try the edges
The signed-in screens are behind a login, so the part you can poke at without an account is the public site: the recipe pages are the same library the app's recipe screens read, and the conditions index covers what the onboarding screens ask you about. The app itself, including the web build of exactly the route tree above, is linked from munchable.app.
If you take one thing from this: in a URL-routed app, a layout redirect is a convenience, not a boundary. The boundary is whatever every entry point has in common, and a deep link is an entry point.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.