●SDK58 — Expo SDK 58 is still in beta (September 15). Stable lands after React Native 0.88 ships, planned for October 12, and Expo Modules 2.0 stays beta until then●10/22 — 24 days until Apple opens Volume Purchasing. Multiseat purchases have been on by default since September 16, so decide now whether to keep them●REVIEW — A common question: the automated pre-review check flags a missing entitlement you never added. One developer opened the .ipa, confirmed it, and passed without a rebuild●NEW — Building the same list screen with Opus 5.5 and GPT-6 Sol, and how we chose effort●NATIVE — After Shopify's back-to-native post, someone built the same app in React Native and native and measured on device. Useful input for any migration debate●CREDITS — How far does "no credits for AI errors" actually go? We are logging a day where the same fix was requested three times to draw the line●SDK58 — Expo SDK 58 is still in beta (September 15). Stable lands after React Native 0.88 ships, planned for October 12, and Expo Modules 2.0 stays beta until then●10/22 — 24 days until Apple opens Volume Purchasing. Multiseat purchases have been on by default since September 16, so decide now whether to keep them●REVIEW — A common question: the automated pre-review check flags a missing entitlement you never added. One developer opened the .ipa, confirmed it, and passed without a rebuild●NEW — Building the same list screen with Opus 5.5 and GPT-6 Sol, and how we chose effort●NATIVE — After Shopify's back-to-native post, someone built the same app in React Native and native and measured on device. Useful input for any migration debate●CREDITS — How far does "no credits for AI errors" actually go? We are logging a day where the same fix was requested three times to draw the line
Your Expo Router App Flashes the Sign-In Screen on Every Launch. Rebuild the Guard With a Third State: Not Known Yet
Signed in, yet the sign-in screen flashes at launch; signed out, yet the screen never changes. How I rebuilt Expo Router Protected routes around three Supabase auth states, including the one the docs never name, and where the _layout.tsx files have to live.
The evening I connected Supabase sign-in to a prototype I'd built in Rork, I kept relaunching the app on my iPhone. I was signed in. And yet, on every launch, the sign-in screen appeared for a split second before the home screen took over.
At first I told myself it was my imagination. Then I recorded the screen and stepped through it frame by frame, and there it was: the sign-in screen, drawn and then discarded. A second symptom rode along with it. Tapping sign out changed nothing on screen; only killing the app and reopening it brought the sign-in screen back.
The same symptoms are gathered in expo/expo issue #37305. It has been open since June 2025, with 26 comments so far: people stuck on a black screen, people whose sign-out never refreshes the view, people who copied the documentation word for word and still got nowhere.
What I'd like to say first is that Protected routes were not broken. I was. I had been holding "is this person signed in?" as a yes or a no, and reality has a third answer, "I don't know yet." At launch, an app is almost always sitting in that third state.
I was forcing a three-valued reality into a two-valued guard
Stack.Protected takes a boolean guard. Signed in means true, otherwise false. That design is fine.
The trouble was what I did with the moment right after launch, when the session hadn't been read yet. I rounded it down to false. While session was null, the app treated me as signed out and drew the sign-in screen. A few hundred milliseconds later the session finished loading, the guard flipped, and the home screen replaced it.
Actual state
Two-valued guard
What gets drawn
Three-valued handling
Not read yet
false (null rounded down)
Sign-in screen, briefly
Draw neither
Signed out
false
Sign-in screen
Sign-in screen
Signed in
true
Home
Home
It was the same shape as a bug I'd fixed in my wallpaper app, where a test device with the ad-free purchase showed one ad right after launch. The purchase state hadn't finished loading, and the code treated "not loaded" as "not purchased." The moment I introduced a "not known yet" state, the ad stopped appearing. As an indie developer I've fallen into this hole more than once, and here I was falling into it again with authentication.
While the answer is unknown, draw neither screen. The entire fix fits in that sentence.
What you need
Piece
Requirement
Note
An Expo project generated by Rork
expo-router 5 or later (SDK 53+)
Protected routes arrived in SDK 53
@supabase/supabase-js
v2
The version Rork installs via Connect Supabase is enough
expo-splash-screen
Bundled
Used to hold the splash while the state is unknown
A device or Expo Go
One iOS or Android device
Something that can record its screen makes verification easier
A project connected through Rork's Connect Supabase already has a client along the lines of lib/supabase.ts, storage adapter included. Keep all of it. The only thing we replace is how the auth state is held.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to trace the sign-in flash at launch back to the exact value your _layout.tsx hands to guard, instead of guessing
✦You'll have an AuthProvider that holds three states, not known, signed out, and signed in, wired to Supabase and Expo Router in a form that runs today
✦You'll avoid the black screen and the stuck sign-out that fill a 26-comment issue thread, by placing group layouts correctly and keeping guards flat
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start by pinning the app-wide auth state to exactly three values: unknown, signed-out, and signed-in. Don't pass the session object around as the state itself. Keep the status and the session as separate fields.
// providers/auth.tsximport { createContext, useContext, useEffect, useState } from 'react';import type { Session } from '@supabase/supabase-js';import { supabase } from '@/lib/supabase';export type AuthStatus = 'unknown' | 'signed-out' | 'signed-in';type AuthValue = { status: AuthStatus; session: Session | null;};const AuthContext = createContext<AuthValue>({ status: 'unknown', session: null });// How long we're willing to stay "unknown". I settled on 3 secondsconst RESOLVE_TIMEOUT_MS = 3000;export function AuthProvider({ children }: { children: React.ReactNode }) { const [value, setValue] = useState<AuthValue>({ status: 'unknown', session: null }); useEffect(() => { let cancelled = false; const apply = (session: Session | null) => { if (cancelled) return; setValue({ status: session ? 'signed-in' : 'signed-out', session }); }; // 1) Read the stored session; refreshes it if needed const initial = supabase.auth.getSession().then(({ data, error }) => { if (error) throw error; return data.session; }); // 2) Timeout and errors both fall to "signed out". Never open a protected screen on an undecided state const timeout = new Promise<Session | null>((resolve) => setTimeout(() => resolve(null), RESOLVE_TIMEOUT_MS) ); Promise.race([initial, timeout]).then(apply).catch(() => apply(null)); // 3) Every later change (sign in, sign out, token refresh) arrives here const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { apply(session); }); return () => { cancelled = true; subscription.unsubscribe(); }; }, []); return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;}export function useAuth() { return useContext(AuthContext);}
The part that matters is that every path, success, failure, or exception, leaves unknown. If even one path forgets to, the splash screen we hold in the next step never goes away.
The 3-second ceiling is my own call. I put it there for launches in airplane mode, where a token refresh can hang. Falling to "signed out" on timeout means an offline user who is really signed in gets dropped onto the sign-in screen once. I'd rather accept that than open a protected screen on a state I couldn't decide. When connectivity returns, onAuthStateChange picks it up and the app moves to home on its own.
Step 2: In the root _layout.tsx, don't render the navigator while unknown
One comment in the issue thread describes it precisely: if the condition is the same before and after launch, everything works; if it changes during launch, the app freezes on a black screen. On my device, that was exactly it. The guard was flipping mid-launch.
The docs state that when a guard goes from true to false, every history entry for that screen is removed. So the guard isn't failing to act. It's acting too well. Flip it while the navigator is still assembling itself, and it goes to delete history that doesn't exist yet, leaving a screen with nowhere to go. That's my reading of the black screen, at least.
The fix is plain: don't render the Stack at all while the state is unknown. Hold the splash manually, and only bring the navigator up once the state has settled.
// app/_layout.tsximport { useEffect } from 'react';import { Stack } from 'expo-router';import * as SplashScreen from 'expo-splash-screen';import { AuthProvider, useAuth } from '@/providers/auth';// Once, at module load. Stops the splash from hiding by itselfSplashScreen.preventAutoHideAsync().catch(() => {});export default function RootLayout() { return ( <AuthProvider> <RootNavigator /> </AuthProvider> );}function RootNavigator() { const { status } = useAuth(); useEffect(() => { // Hide the splash only at the moment the state settles if (status !== 'unknown') { SplashScreen.hideAsync().catch(() => {}); } }, [status]); // While unknown, don't assemble the navigator. This stops both the black screen and the sign-in flash if (status === 'unknown') { return null; } const signedIn = status === 'signed-in'; return ( <Stack screenOptions={{ headerShown: false }}> {/* Always hand guard a boolean, never the session object */} <Stack.Protected guard={signedIn}> <Stack.Screen name="(app)" /> </Stack.Protected> <Stack.Protected guard={!signedIn}> <Stack.Screen name="(auth)" /> </Stack.Protected> </Stack> );}
You'll see plenty of examples that write guard={session} and pass the object straight through. I keep it as a boolean. I don't have to think about null versus undefined versus an empty object each time, and a name like signedIn still carries its meaning when I read the file months later.
I ran into the same shape on the Lab websites, where the paywall was drawn before the client-side membership check came back, so I, a member, saw a lock for a blink. The answer was identical there: until the check returns, draw neither version. It didn't matter whether it was web or native.
Step 3: Put a _layout.tsx in every group and register every screen
This is where most of the people who copied the docs and still failed got caught. I got caught too. I hadn't put a _layout.tsx inside the (app) folder.
The Expo Router basics say a layout file isn't always required. But the issue thread has comment after comment saying that adding _layout.tsx to the group made it work. My experience matched. If Stack.Screen name="(app)" points at a folder with no layout, the group doesn't seem to register as a screen at all.
app/├── _layout.tsx ← The root from Step 2. Protected branching lives here and only here├── (auth)/│ ├── _layout.tsx ← Required. <Stack /> alone is enough│ ├── sign-in.tsx│ └── sign-up.tsx└── (app)/ ├── _layout.tsx ← Required ├── index.tsx └── settings.tsx
// app/(app)/_layout.tsximport { Stack } from 'expo-router';export default function AppGroupLayout() { // The Stack picks up screens in this group from file names. // No nested Protected here (more on nesting below) return <Stack screenOptions={{ headerShown: false }} />;}
// app/(auth)/_layout.tsximport { Stack } from 'expo-router';export default function AuthGroupLayout() { return ( <Stack screenOptions={{ headerShown: false }}> {/* No leading slash in names. This differs from router.replace('/sign-in') */} <Stack.Screen name="sign-in" /> <Stack.Screen name="sign-up" /> </Stack> );}
One more report near the end of the thread: forgetting to register a single screen broke all of the auth routing. If you list screen names explicitly in a layout, list every screen at that level. A missed one doesn't fail as "that screen doesn't work." It fails as "the whole thing goes black."
You also can't declare the same screen in two groups. If there's a shared screen you want both before and after sign-in, place it once in one group and link to it from the other.
Verify with four launch patterns
The only reliable way I found to confirm the fix was to record the screen and step through frames. "I think it's gone now" from watching with my eyes was, in my case, worth nothing.
Pattern
Steps
Expected
Launch signed in
Sign in, fully quit the app, reopen
Splash straight to home. Not one frame of the sign-in screen
Launch signed out
Sign out, quit, reopen
Splash straight to sign-in
Sign out in-app
Tap sign out in settings
Sign-in screen appears immediately, no restart
Launch in airplane mode
Signed in, enable airplane mode, open
Sign-in screen within 3 seconds; home once connectivity returns
To make the third pattern hold, the sign-out handler must not navigate on its own. When the guard flips, Expo Router removes the (app) history for us, so all we do is await signOut.
// part of app/(app)/settings.tsximport { supabase } from '@/lib/supabase';async function handleSignOut() { const { error } = await supabase.auth.signOut(); if (error) { // If signOut fails (offline, etc.), stop here. // Forcing router.replace('/sign-in') leaves the stored token in place with only the UI pretending to be signed out console.warn('sign-out failed', error.message); return; } // On success, do nothing. onAuthStateChange → status becomes signed-out → guard flips → screen changes}
Pitfalls: nested Protected, redirectTo, and the real line of defense
Don't nest. A two-level check such as "signed in and onboarding not complete" written as nested Stack.Protected blocks produced reports in the thread of being sent to the inner screen even though the outer guard was false. I stopped nesting and flattened everything.
A flag like onboarded has its own "unknown" too. Build it the same way as the AuthProvider in Step 1, and hold the navigator until both states have settled. That also removes the onboarding screen flashing at launch, which is the same bug wearing a different name.
redirectTo starts at SDK 58. The redirectTo prop, which names where a denied navigation lands, is documented as SDK 58 and later. As of today SDK 58 is still in beta, so on earlier versions you work with the default behavior: redirect to the anchor route, or to the first screen that's available. That's why (auth) sits last in the code above. When nobody is signed in, the first available screen is that one.
Open your own app/_layout.tsx and look at what you're passing to guard. If it's an object like session, make it a boolean. Then check whether you render the Stack while the state is still unknown. Those two checks are all it takes.
I still feel the pull of the two-valued version. Adding a third state is a chore, and on most launches it's gone in a blink. Even so, the one line I hold to, even in a throwaway prototype, is this: while I can't decide, I show neither.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.