◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-03-13Intermediate

Navigation in Rork Apps Without the Rebuild — An Expo Router Blueprint

Understand the Expo Router file structure Rork generates, then layer tabs, stacks, modals, auth guards, and deep links without rework — including the mistakes that cost me half a day.

Expo Router6NavigationReact Native239Rork585Deep Linking6Tab BarStack Navigation

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".

💡
To configure deep linking with Rork, prompt it with: "Set up deep linking with the scheme myapp, and handle the /item/:id path to open the detail screen." Rork will update both app.json and the relevant route files.

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.

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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
⬡ Dev Tools2026-08-22
Every bulk replace exited zero. The damage was in the lines I did not delete
Run a bulk replace over generated code and the breakage lands on the neighbouring lines, not the matched ones. Here is what broke in a live project, and a dependency-free guard that checks the invariants a replace must preserve.
⬡ Dev Tools2026-08-14
Find the native edits expo prebuild will erase before you upgrade to SDK 57
Expo SDK 57 makes expo prebuild clear and regenerate ios and android by default. Here is how to audit your hand edits first, move them into config plugins, and why 57.0.9 matters for Reanimated apps.