Keep unsigned users off product screens with one Expo Router root gate

If a product screen can mount before you know who is signed in, the file tree is doing the wrong job. The fix is not a Redirect at the top of every screen. It is a file convention that splits signed-out routes from product routes, plus one guard at the root stack that decides which group is allowed to exist.
Expo Router calls this protected routes. The current API is Stack.Protected with a guard boolean. The authentication guide uses the same idea with route groups. Older apps still use a layout-level Redirect. All three are the same product decision: evaluate auth before a home, settings, or billing screen is in the tree.
This post is only that gate. Session survival after the token expires, and recovering a deep link after sign-in, are a different problem — see how production auth guards recover expired sessions and deep links when you get there.
Split the file tree so product files cannot be the default

Expo Router maps files under app/ to screens. If app/index.tsx is the signed-in home, an unsigned user who opens the app already has a product route in the navigator. A later Redirect is cleanup, not a gate.
The convention is two route groups next to a root layout:
app/
_layout.tsx # root stack; this is the only gate
(auth)/
_layout.tsx
sign-in.tsx
sign-up.tsx
(app)/
_layout.tsx
index.tsx
settings.tsx
billing.tsxParentheses are groups. (auth) and (app) do not appear in the URL. They exist so you can attach different layouts and so the root stack can name each group as a single screen.
Rules that keep this honest:
- Signed-out screens live only under
(auth). Sign-in, sign-up, password reset, magic-link confirm. Nothing that reads a user id from session. - Product screens live only under
(app)(or another private group name). Home, settings, billing, the screen that lists the user’s projects. If a file needs a session to be meaningful, it does not belong next tosign-in.tsx. - The root
app/_layout.tsxis the only place that chooses a group. Child layouts inside(auth)and(app)style stacks and tabs. They do not re-run the signed-in check.
That last point is the whole post. Ad-hoc per-screen redirects fail because every new file is a new chance to forget the check. A group the root stack can hide cannot mount, even when someone adds app/(app)/new-feature.tsx and never thinks about auth.
If an AI agent is generating these files, the group names have to be in the prompt. A skill that only says “add a login screen” will drop sign-in.tsx at the app/ root and leave product routes reachable. Skills that keep Expo file conventions in the agent loop are how you stop that drift; the gate below assumes the tree is already split.
Guard both groups at the root stack

On current Expo Router, the root layout does not Redirect. It declares which stack screens are allowed. From the protected routes guide:
import { Stack } from 'expo-router';
import { useAuth } from '../auth/AuthProvider';
export default function RootLayout() {
const { isReady, isSignedIn } = useAuth();
if (!isReady) {
return null;
}
return (
<Stack>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
</Stack>
);
}Two facts about this shape, both from that guide and from the authentication guide:
guardis a boolean. When it istrue, the nested screens are in the navigator. When it isfalse, they are not. You do not navigate to(app)as a side effect of sign-in; you flipisSignedInand the stack drops the group that no longer matches.- You guard both sides.
guard={isSignedIn}on(app)keeps product screens out of an unsigned session.guard={!isSignedIn}on(auth)keeps sign-in out of a signed-in session. One-sided protection is how you get a back-stack that still containssign-inafter a successful login, or a product tab that still exists after logout.
The authentication guide’s file layout is the same groups: an auth group for the unauthenticated flow and a private group for the rest of the app, with the protected stack at the root. Copy that structure. Do not invent a third “maybe” group that mixes both.
Stack.Screen name="(app)" matches the folder. If you rename the private group, rename the screen. A typo here fails closed in the sense that the product group never registers — which is annoying in development and much better than failing open.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Wait until the session is loaded before the guard means anything
isSignedIn === false is not the same as “we have not read storage yet.”
On a cold start the auth provider is usually async: read a token from secure storage, maybe hit /me, then set state. If the first render treats “unknown” as “signed out,” the root stack mounts (auth), the user sees sign-in, then isSignedIn flips and the stack swaps to (app). That is a flash of the wrong screen. The inverse flash — a frame of home before bounce-to-sign-in — is worse, because it confirms the product tree was reachable.
Do not put the loading branch inside (app)/_layout.tsx. By the time that layout runs, the product group already passed the guard. Loading belongs in the provider and at the root.
One root gate versus a redirect on every screen
The pattern that does not scale:
// app/(app)/settings.tsx — do not do this as your only check
import { Redirect } from 'expo-router';
export default function Settings() {
const { isSignedIn } = useAuth();
if (!isSignedIn) {
return <Redirect href="/sign-in" />;
}
return <SettingsForm />;
}Problems that show up in real trees:
- Every file is a policy. A new screen that forgets the
Redirectis a hole. Code review will not catch it once the app has twenty product files. - The screen still mounted.
Redirectruns in render. Hooks above it already ran. Effects that fetch/billingalready fired. You spent a round trip to learn you should not have been there. - Guards disagree. Settings redirects to
/sign-in. Billing redirects to/(auth)/sign-in. Home checks a differentuserfield. You now have three definitions of “signed in.” - The navigator still knows the product route. Deep links and back behavior can land on a screen whose only protection is a render-time bounce.
Stack.Protected at the root removes the group. Screens under (app) are not in the stack, so they do not mount, do not fetch, and are not a back target. The authentication guide and the protected-routes guide both put that decision in the root layout for this reason.
You can still use Redirect for in-flow navigation (after sign-up, go to onboarding). That is a user-path choice, not an access gate.
This gate is client navigation, not API authorization
A root navigator guard only decides which screens exist in the client tree. It is not row-level security and not an API authorization check. Your server still rejects unauthenticated requests. Treat the file-tree gate as UX and navigation hygiene, then keep the same session rules on the API.
What to check before you ship
Walk this list against the real tree, not the diagram:
- No product file outside the private group. Search
app/for screens that assume a user. They belong under(app)(or your private name). Sign-in and friends belong under(auth). - One evaluator.
isSignedIncomes from one provider. Root layout is the only file that passes it toguard(or, on SDK 52, the only layout thatRedirects). - Both groups are guarded. Unsigned users cannot mount
(app). Signed-in users cannot mount(auth)as a live stack. isReadyis false until storage (and optional/me) finish. Confirm on a slow device that you never see sign-in then home, or home then sign-in, on a cold start.- Logout flips the same boolean. After sign-out,
(app)is gone. If a product screen is still on the back stack, the guard is not actually removing the group. - You did not copy this check into API routes. The client gate is not your authorization story.
When those hold, new product files inherit the gate because they are files in a group the root stack can hide. That is the outcome: product screens do not exist in the navigator until the session is known and signed in.
Sources
- Expo Router — protected routes (
Stack.Protected) - Expo Router — authentication (route groups + root guard)
- Expo Router — authentication rewrites (SDK 52 and earlier)
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included