RORK LABJP
PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updatesPLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
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 Native234Rork548Deep 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 $15 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-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.
Dev Tools2026-08-10
Switching to Signed URLs Killed My Image Cache — Decoupling expo-image Keys from the URL
Signed URLs rewrite their query string on every expiry, so a URL-keyed cache never hits. Here is how to derive a stable key and drive expo-image's writeToCacheAsync and readFromCacheAsync yourself, with measured results.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →