How RapidNative Handles Navigation and Routing in Generated Apps
(156 chars):
By Suraj Ahmed
20th Jun 2026
Last updated: 20th Jun 2026
Navigation is the part of a mobile app that users never talk about until it breaks. Tap a tab, push a screen, dismiss a modal — when it works, it disappears. When an AI tries to generate it, things tend to get weird fast: phantom back buttons, screens stacked three layers deep, tabs that refuse to render on a real device.
When we built RapidNative — our AI mobile app builder that turns natural language prompts into production-ready React Native and Expo apps — navigation was the single most failure-prone area of code generation. So we redesigned the way the model is allowed to reason about routes. This post is a tour of how generated apps handle navigation under the hood: what library we picked, what we forbid the model from doing, and the small set of conventions that turn "the AI made me an app" from a fragile demo into something you can ship.
Tab and stack navigation are the backbone of nearly every modern mobile app — Photo by Daniel Korpai on Unsplash
TL;DR: Expo Router, file-based routing, static paths only
RapidNative generates apps using Expo Router, Expo's file-based routing system. Every screen the AI produces lives in the app/ directory, navigation structure is expressed through _layout.tsx files, and routes are restricted to static paths — no dynamic segments, no useLocalSearchParams, no surprises. This combination is what lets the model generate navigation that actually compiles, runs, and previews in the browser without human cleanup.
Why we chose Expo Router over React Navigation
The first big call was the routing library. In React Native land there are essentially two choices: React Navigation, the long-standing imperative library, or Expo Router, the newer file-based system layered on top of it.
For human developers, the choice is a matter of taste. For an AI generator, the choice has consequences:
- React Navigation requires the model to maintain a navigation tree as a JavaScript object —
Stack.Navigator,Tab.Navigator,Stack.Screendeclarations, route param types, and a separate file that wires it all up. Add a screen and the model has to remember to register it in two or three places. - Expo Router turns every file in the
app/directory into a route automatically. Adding a screen means creating a file. The "navigation config" is the file system.
For an LLM that generates code one file at a time, file-based routing is dramatically more reliable. There is no central manifest to forget to update. There is no chance of a screen existing in the codebase but being invisible at runtime because it wasn't registered. The file is the route. That's it.
Look at the dependencies in a generated project's package.json and you'll see this commitment expressed concretely: there is no @react-navigation/native, no @react-navigation/bottom-tabs, no @react-navigation/stack. Just expo-router, used as the single source of truth for navigation. This eliminates a whole class of "package A and package B disagree about who owns the back button" bugs that plague hand-written React Native apps.
What a generated app's file structure actually looks like
Here's the shape of the app/ directory in a typical RapidNative-generated project — for instance, a food-ordering app with a bottom tab bar:
app/
├── _layout.tsx # Root layout — wraps the whole app in a Stack
├── (tabs)/
│ ├── _layout.tsx # Tabs layout — defines the bottom tab bar
│ ├── index.tsx # Home tab (default route)
│ ├── search.tsx # Search tab
│ ├── orders.tsx # Orders tab
│ └── profile.tsx # Profile tab
├── restaurant.tsx # Restaurant detail screen (pushed from Home)
├── checkout.tsx # Checkout flow
└── settings.tsx # Settings screen (pushed from Profile)
A few patterns are worth flagging:
_layout.tsxis the navigator definition. The root_layout.tsxdeclares aStack. The(tabs)/_layout.tsxdeclares aTabsnavigator. Every screen inside a folder inherits that folder's navigator.(tabs)is a route group. The parentheses tell Expo Router that the group is organizational — it does not show up in the URL. So/index.tsxinside(tabs)/is still reachable at/, not/(tabs)/. This is essential for the model to reason about correctly: the URL is the canonical identity of a screen.- Non-tab screens live at the root. Anything that should be pushed on top of the tab bar (a detail screen, a checkout flow) sits outside
(tabs)/so that pushing it covers the tab bar instead of being constrained inside one tab.
This convention is encoded in our project templates — the scaffolds live under tools/project-templates/ — and reinforced in the system prompts that the model reads before every generation. The result is that no matter which seed template a project starts from, the navigation skeleton is recognizable and consistent.
A bottom tab bar with four tabs is the most common navigation shell in generated apps — Photo by Hal Gatewood on Unsplash
The root layout: stack on top of tabs
The root app/_layout.tsx in a generated project looks something like this:
import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="restaurant" />
<Stack.Screen name="checkout" options={{ presentation: 'modal' }} />
</Stack>
</SafeAreaProvider>
);
}
Three things are happening:
SafeAreaProviderwraps everything. Without it,SafeAreaView(used in every screen) can't compute insets. This is a hard rule in our generation — the provider is mandatory at the root.- The default header is hidden. Generated apps build their own headers using styled components so they can look the same on iOS and Android. Letting Expo Router draw a default header on top of a custom one produces an ugly double-bar that wastes vertical space.
(tabs)is registered as aStack.Screen. This is the part that surprises people moving from React Navigation: the tabs are a screen from the Stack's point of view. Anything you push (a detail page, a checkout) sits on top of the entire tab bar.
The tab layout: where most generated apps spend their time
app/(tabs)/_layout.tsx is where the bottom tab bar gets configured:
import { Tabs } from 'expo-router';
import { Home, Search, ShoppingBag, User } from 'lucide-react-native';
export default function TabsLayout() {
return (
<Tabs screenOptions={{ headerShown: false, tabBarActiveTintColor: '#FF5A1F' }}>
<Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: ({ color }) => <Home color={color} /> }} />
<Tabs.Screen name="search" options={{ title: 'Search', tabBarIcon: ({ color }) => <Search color={color} /> }} />
<Tabs.Screen name="orders" options={{ title: 'Orders', tabBarIcon: ({ color }) => <ShoppingBag color={color} /> }} />
<Tabs.Screen name="profile" options={{ title: 'Profile', tabBarIcon: ({ color }) => <User color={color} /> }} />
</Tabs>
);
}
The model is constrained to use icons from lucide-react-native — a single, curated, NativeWind-compatible icon set. We picked one icon library and forbid the model from importing alternatives. This is the kind of decision that matters in code generation: every time the model can "improvise" on a dependency, you trade a marginal aesthetic improvement for a real reliability cost.
Static routes only — the constraint that makes generation reliable
Here is where RapidNative diverges sharply from a hand-written Expo Router app: the model is not allowed to use dynamic routing.
Specifically, the system prompt forbids:
- Dynamic route segments like
[id].tsx - The hooks
useSearchParams,useLocalSearchParams,useGlobalSearchParams, andusePathname - Any pattern where the route URL carries data the model has to interpret at runtime
Why? Because every dynamic route is a contract between the URL and a hook call somewhere downstream. If the model produces app/restaurant/[id].tsx but forgets to call useLocalSearchParams<{ id: string }>() correctly, the screen renders but the data is missing. If it picks the wrong key, the screen renders blank. Generation reliability collapses, and you, the user, see a screen that "looks right" but is silently broken.
So we restrict navigation to two patterns:
// Programmatic navigation
import { useRouter } from 'expo-router';
const router = useRouter();
router.push('/restaurant');
// Declarative navigation
import { Link } from 'expo-router';
<Link href="/checkout">Checkout</Link>
Static paths. That's it. State that needs to travel between screens travels through a shared store — Zustand, Redux, or React Context — not through URL params. We covered this layer in detail in our post on state management in AI-generated React Native apps, and it's the natural complement to a static-routing world: routes describe where you are, the store describes what you're looking at.
Trade-off: Yes, you lose deep-linkable parameterized URLs. For the apps people actually generate with RapidNative — prototypes, MVPs, internal tools, founder demos — that's a trade we're happy to make. When you export the project and want to add
[id].tsx-style routes, nothing stops you. The generated codebase is plain Expo Router; you own it.
Modals are React Native, not router
Expo Router supports modal screens — you can mark a Stack.Screen with presentation: 'modal' and push it like any other route. Generated apps use this for fullscreen flows (checkout, onboarding) but not for the kind of small modal you see overlaid on a list: filter pickers, confirmation sheets, item details.
For those, the model uses the React Native Modal component or a bottom sheet primitive, controlled by component-local state:
const [open, setOpen] = useState(false);
<Modal visible={open} animationType="slide" transparent>
{/* sheet content */}
</Modal>
The reason is the same as for static routing: a Modal controlled by useState keeps the model's reasoning local. It doesn't have to register a new route, decide whether the route should sit under (tabs) or above it, or remember to dismiss with router.back(). Open and close are just two state transitions. The model gets this right far more often.
Modals, sheets, and checkout flows each map to different navigation primitives — choosing the right one is a design decision the AI is guided through — Photo by Plann on Pexels
The SafeAreaView trap (and how the generator avoids it)
A subtle but common bug in hand-written Expo Router apps: tab screens have a "double bottom padding" problem. The tab bar already accounts for the device's home-indicator inset, and if a screen wraps its content in a SafeAreaView with all edges enabled, the bottom inset gets applied twice. You end up with awkward whitespace under your content.
The generator handles this by injecting a different edges prop depending on where the screen lives:
// Tab screen — bottom is handled by the tab bar
<SafeAreaView edges={['top', 'left', 'right']} className="flex-1">
// Non-tab pushed screen — full edges
<SafeAreaView className="flex-1">
And inside a tab screen's ScrollView, the generator adds contentContainerStyle={{ paddingBottom: 128 }} so the last item isn't hidden behind the tab bar. None of this is conceptually deep — it's just the kind of small, consistent convention that distinguishes a polished generated app from one that "almost works."
The web preview trick: running Expo Router in the browser
Here's a constraint specific to RapidNative that doesn't apply to most app developers: the generated app has to render inside the browser, in real time, in the editor's preview pane, before you've installed anything on a phone.
Expo Router targets React Native first. The browser is a stretch goal. To make file-based routing work in a sandboxed iframe, we built rapidnative-expo-router — a thin compatibility shim that exposes the same API (Stack, Tabs, useRouter, Link) backed by react-router-dom under the hood.
The trick is that the model doesn't know or care about this. It writes:
import { useRouter } from 'expo-router';
router.push('/restaurant');
On a phone, that resolves to native expo-router. In the editor preview, the import is aliased to our shim, which routes through React Router. The generated code is identical. The preview is honest — it actually navigates, the back button works, the tab bar updates — because the routing API surface is the same on both sides.
A small set of utilities under src/shared/utils/expo-router-filters.ts makes this seamless: filterRoutableScreens() skips _layout.tsx and private folders, and filePathToRoutePath() converts app/(tabs)/index.tsx into the URL / (stripping the route group parentheses). When you click into a screen in the editor, those utilities figure out which file in the file tree corresponds to the route you're currently looking at, so the file panel and the preview stay synchronized.
How does the AI decide when to add a new screen?
When you describe a new piece of functionality — "add a screen where users can leave a review" — the model has to make a small but consequential decision: does this become a new tab, a pushed screen, a modal, or part of an existing screen?
The system prompt biases it toward the simplest answer:
- If the feature is content that should replace the current view, push it. A restaurant detail screen pushed from the home tab. A settings screen pushed from profile.
- If the feature is a quick interaction that shouldn't lose the user's context, modal it. Filter pickers, share sheets, confirmation dialogs.
- If the feature is a primary destination the user will return to many times, propose a new tab. But only if the existing tab count is reasonable (four or five tabs max — beyond that, the bar gets crowded).
- If the feature is a section of an existing screen, just add it to the screen. A "recommended" carousel on the home screen doesn't need its own route.
This isn't a hard-coded rule tree; it's prompt guidance the model weighs against your specific request. But the bias toward the simpler option is intentional. Most apps generated by RapidNative have between six and twelve screens. They don't need a routing diagram that looks like a transit map.
Can I export the generated app and add custom navigation?
Yes — and this is the part founders ask about most. The generated codebase is plain Expo + Expo Router + NativeWind. When you export, you get a standard Expo project you can open in any editor, run npx expo start, and modify freely. Want to add a [id].tsx dynamic route? Go ahead. Want to swap in @react-navigation/drawer for a side drawer? Install it and wire it up. The static-routing constraint exists during generation to keep the AI reliable; it doesn't bind your hands once the code is yours.
For most users, the exported app is shippable as-is. We cover the export and publishing path in more depth on the pricing page and in the PRD-to-app flow.
What this looks like in practice
If you start a project from a prompt like "a fitness tracker with workout logging, a stats dashboard, and a profile", the generator will scaffold:
- A
(tabs)group with three tabs: Workouts, Stats, Profile. - A pushed
workout/[name].tsx— but wait, no — a pushedworkout-detail.tsxscreen that reads which workout to display from a Zustand store (because of the static-route rule). - A modal sheet for "log a new set," driven by
useStatein the active workout screen. - A pushed
settings.tsxreachable from Profile.
You can also try this yourself — start from the whiteboard-to-app flow by sketching a quick mockup of your tab bar and let the generator infer the structure.
The principle behind the design
The thread running through all of this is: constraints that make generation reliable are worth more than features that make generation flexible. It's tempting, when building an AI code generator, to support every pattern the human ecosystem supports. Dynamic routes. Multiple navigation libraries. Custom presentations. The model could probably produce all of it, most of the time.
But "most of the time" is the wrong target for code generation. A user who gets a working app eight times out of ten is going to get frustrated on attempt three. A user who gets a working app every time, within a deliberately narrower design space, will keep building. That's the bet behind the navigation rules — and once you see it, you start noticing the same shape in our 4-step LLM pipeline, our state management architecture, and the way we restrict UI components to a single styling system.
Build an app with this in mind
If you've been holding off on building a mobile app because the navigation layer scared you, here's the news: you don't have to think about it. Describe your screens, hit generate, and the routing falls out of the file structure. Push, pop, tab — it works on iOS, Android, and the in-browser preview from the first generation.
Start building free at RapidNative. No credit card, no install, no setup — describe the app you want and watch the navigation graph build itself.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.