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

> Use Expo Router route groups and one root Stack.Protected guard so product screens never mount for unsigned users.
> By Dave · 2026-09-18
> Source: https://otf-kit.dev/blog/expo-router-protected-routes-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](https://otf-kit.dev/blog/expo-router-auth-guards-production) when you get there.

## Split the file tree so product files cannot be the default

![Byte sorts auth routes from product routes in the file tree](https://cdn.otf-kit.dev/blog/expo-router-protected-routes-gate/inbody-01-split-20260918a.png)

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:

```text
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.tsx
```

Parentheses 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 to `sign-in.tsx`.
- **The root `app/_layout.tsx` is 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](https://otf-kit.dev/blog/expo-skills-for-ai-agents) are how you stop that drift; the gate below assumes the tree is already split.

## Guard both groups at the root stack

![Luna and Nova set one root gate for the stack](https://cdn.otf-kit.dev/blog/expo-router-protected-routes-gate/inbody-02-root-gate-20260918a.png)

On current Expo Router, the root layout does not `Redirect`. It declares which stack screens are allowed. From the [protected routes guide](https://docs.expo.dev/router/advanced/protected/):

```tsx
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](https://docs.expo.dev/router/advanced/authentication/):

1. **`guard` is a boolean.** When it is `true`, the nested screens are in the navigator. When it is `false`, they are not. You do not navigate to `(app)` as a side effect of sign-in; you flip `isSignedIn` and the stack drops the group that no longer matches.
2. **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 contains `sign-in` after 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.

## 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:

```tsx
// 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 `Redirect` is a hole. Code review will not catch it once the app has twenty product files.
- **The screen still mounted.** `Redirect` runs in render. Hooks above it already ran. Effects that fetch `/billing` already 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 different `user` field. 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:

1. **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)`.
2. **One evaluator.** `isSignedIn` comes from one provider. Root layout is the only file that passes it to `guard` (or, on SDK 52, the only layout that `Redirect`s).
3. **Both groups are guarded.** Unsigned users cannot mount `(app)`. Signed-in users cannot mount `(auth)` as a live stack.
4. **`isReady` is 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.
5. **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.
6. **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`)](https://docs.expo.dev/router/advanced/protected/)
- [Expo Router — authentication (route groups + root guard)](https://docs.expo.dev/router/advanced/authentication/)
- [Expo Router — authentication rewrites (SDK 52 and earlier)](https://docs.expo.dev/router/advanced/authentication-rewrites/)
