Navigation in Rork Apps Without the Rebuild — An Expo Router Blueprint
As an indie developer building apps with Rork, I've learned that navigation is usually the first thing to break when an app grows. One of my own projects started fine with four tabs, but once I added detail screens and a settings modal, the transitions tangled and I ended up rebuilding the whole navigation layer. The root cause was simple: I hadn't mapped which file owned which screen before reading the generated code.
Apps built with Rork use Expo Router — a file-system-based routing library that works similarly to Next.js for the web. Understanding it up front makes your Rork prompts far more precise and saves you from the kind of costly rebuild I just described. This guide walks through tabs, stacks, and modals, then auth guards and deep linking, in the order you'd actually implement them.
This guide walks through tab, stack, modal, and authenticated navigation patterns, plus deep linking — all in the context of Rork.
What Is Expo Router?
Expo Router maps your app/ directory structure directly to routes and navigation. Each file becomes a screen; each folder can become a navigation group or layout.
Rork sets up Expo Router automatically, but knowing the file structure helps you write better prompts and understand what the AI is generating on your behalf.
Basic Directory Structure
app/
├── _layout.tsx ← Root layout (shared config)
├── index.tsx ← Home screen (/)
├── (tabs)/ ← Tab navigation group
│ ├── _layout.tsx ← Tab definitions
│ ├── home.tsx ← Home tab
│ ├── explore.tsx ← Explore tab
│ └── profile.tsx ← Profile tab
├── (auth)/ ← Auth group
│ ├── login.tsx
│ └── signup.tsx
└── modal.tsx ← Full-screen modal
Folders wrapped in parentheses () are route groups — they organize layouts without affecting the URL path. This is the one rule worth internalizing before you write a single router.push. The real path for (tabs)/home/[id].tsx is /home/123, not /tabs/home/123. I spent half a day chasing a "nothing happens on tap" bug that turned out to be router.push('/tabs/home/1'). Groups are the only place where the directory name and the URL disagree.
Tab Navigation
Bottom tab bars are the most common navigation pattern in mobile apps. Tell Rork what tabs you need and it will generate the correct Expo Router structure.
Example Rork prompt:
Create an app with four bottom tabs: Home, Search,
Favorites, and Profile. Each tab should have an
Ionicons icon and a label.
A typical generated (tabs)/_layout.tsx:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
headerShown: false,
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="favorites"
options={{
title: 'Favorites',
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}Stack Navigation (Push / Pop)
For detail screens, forms, or any flow where users navigate forward and back, use stack navigation nested inside a tab.
Nested Stack Inside a Tab
app/
└── (tabs)/
├── _layout.tsx
└── home/
├── index.tsx ← List screen (/home)
└── [id].tsx ← Detail screen (/home/123)
Dynamic routes ([id].tsx) accept URL parameters:
// app/(tabs)/home/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';
export default function DetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={{ flex: 1, padding: 16 }}>
<Text>Item ID: {id}</Text>
</View>
);
}Navigating to the Detail Screen
import { router } from 'expo-router';
<TouchableOpacity onPress={() => router.push(`/home/${item.id}`)}>
<Text>{item.title}</Text>
</TouchableOpacity>Example Rork prompt:
Build a news app with a list screen and a detail screen.
Tapping a news item navigates to the detail screen using the
article ID as a URL parameter. A back button returns to the list.
Modal Screens
Use modals for settings, confirmations, or any screen that should slide up over the current context.
// app/_layout.tsx (root layout)
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: 'modal',
title: 'Settings',
}}
/>
</Stack>
);
}Opening the modal from anywhere in the app:
import { router } from 'expo-router';
<TouchableOpacity onPress={() => router.push('/modal')}>
<Text>Open Settings</Text>
</TouchableOpacity>Authentication Guard
Redirecting unauthenticated users is a pattern almost every app needs. Here's how to implement it cleanly with Expo Router:
// app/_layout.tsx
import { useEffect } from 'react';
import { useRouter, useSegments, Slot } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// Not signed in — redirect to login
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// Already signed in — go to the app
router.replace('/(tabs)/home');
}
}, [user, loading, segments]);
return <Slot />;
}Example Rork prompt:
Build an app where unauthenticated users are redirected to
the login screen. After signing in, redirect to the home tab.
Manage auth state with Zustand.
Deep Linking
Deep links let external sources — push notifications, websites, other apps — open a specific screen in your app.
Configure app.json
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/item"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Expo Router automatically handles deep link resolution from this config. For example, opening myapp://home/123 routes to app/(tabs)/home/[id].tsx with id = "123".
When Navigation Fires Before the Layout Mounts
The bug that usually shows up right after you add an auth guard is Attempted to navigate before mounting the Root Layout component. Your useEffect calls router.replace() while the root navigator hasn't finished mounting.
In my case the auth state loaded from AsyncStorage faster than the first render, so the redirect ran too early. The fix has two gates:
// app/_layout.tsx
import { useEffect } from 'react';
import { Slot, useRouter, useSegments, useRootNavigationState } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useAuth } from '@/hooks/useAuth';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
const navState = useRootNavigationState();
useEffect(() => {
// 1. Wait until the navigator has actually mounted
if (!navState?.key) return;
// 2. Wait until auth state has been restored
if (loading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
router.replace('/(tabs)/home');
}
SplashScreen.hideAsync();
}, [navState?.key, user, loading, segments]);
return <Slot />;
}useRootNavigationState() only returns a key once the navigator is live. Gate your redirect on it and the launch crash disappears. Hiding the splash screen manually in the same effect also removes the brief flash of the login screen on cold start.
One structural note. Where the auth decision itself lives is worth thinking through alongside implementing user authentication in Rork with Firebase and Supabase. Keeping the navigation layer as a thin consumer of that decision is what makes swapping the auth backend later a small change rather than a rewrite.
Put the Not-Found Screen In Early
Once deep linking is on, someone will eventually open your app with a stale or mistyped URL. Expo Router reserves the filename +not-found.tsx for exactly this case.
// app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { View, Text } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "We couldn't find that screen" }} />
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<Text style={{ marginBottom: 16 }}>
This link may be out of date, or the screen has moved.
</Text>
<Link href="/(tabs)/home" replace>
<Text style={{ color: '#007AFF' }}>Back to home</Text>
</Link>
</View>
</>
);
}The replace prop matters. Without it, the not-found screen stays in the history stack, and a back gesture from home drops the user straight back into the error state. It's a small thing that feels wrong immediately on a real device.
You don't need a fresh build to test deep links, either:
# iOS simulator
xcrun simctl openurl booted "myapp://home/123"
# Android emulator
adb shell am start -W -a android.intent.action.VIEW -d "myapp://home/123"Verify two things before you submit: a bad ID lands on +not-found, and a valid ID opens the detail screen. That's one fewer review rejection.
Navigation Best Practices with Rork
These five principles will improve both your prompts and the quality of generated navigation code.
1. Define your navigation structure before prompting
Map out which screens belong in which tab, which screens are modals, and what the auth flow looks like before writing a single prompt. A clear mental model leads to cleaner generated code.
2. Be explicit about transitions in your prompts
Phrases like "navigate to the detail screen," "replace the login screen with home after sign-in," and "show this as a modal" directly translate into the right Expo Router calls.
Phrases like "navigate to the detail screen," "replace the login screen with home after sign-in," and "show this as a modal" directly translate into the right Expo Router calls. For prompt granularity itself, how to write better prompts for Rork covers the tactics I lean on.
Keep (auth)/ for unauthenticated flows, (tabs)/ for the main app, and (admin)/ for any internal tooling. This structure is clean, scalable, and what Rork naturally generates when prompted clearly.
4. Know when to use push vs. replace
router.push()→ adds to history (user can go back)router.replace()→ replaces history entry (login redirects, onboarding completion)router.back()→ goes to the previous screen
5. Centralize header styles in _layout.tsx
Rather than styling headers per-screen, use screenOptions in _layout.tsx to keep consistent header appearance across an entire stack or tab group.
Where to Go Next
The habit that finally spared me the rebuild I mentioned at the start was sketching the directory structure on paper before adding a screen. Groups don't appear in the URL. Redirects wait for mount. The not-found screen goes in early. Internalize those three and most of the rework disappears.
If you want something concrete to do today: open app/, trace how your groups nest, and add a single +not-found.tsx. That alone gets you ready to take deep linking seriously.
Start with tabs, then layer stacks and modals as the app asks for them. That's the order I've settled into, and it has held up across several projects.