●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Advanced Navigation Patterns in Rork Max — Nested Structures, Custom Transitions, Auth Flows, and Shared Element Transitions
An advanced Rork Max navigation guide grounded in apps I have run solo since 2013. Beyond nested Expo Router structures, auth flow branching, and shared element transitions, it covers six production pitfalls and their fixes with working code.
I'm Masaki Hirokawa, an artist and indie app developer. I've been running iOS and Android apps solo since 2013 — mostly quiet, everyday utilities like wallpaper, calming-tone, and intention-style apps — which have reached around 50 million cumulative downloads. In apps people open every single day, whether the screen transitions feel right under your thumb matters more for retention than any flashy feature. I spend time on navigation because it's the place where users quietly slip away, and I've learned that the hard way more than once.
Setup and context — Why Navigation Design Matters
The quality of a mobile app is fundamentally shaped by its navigation architecture. No matter how polished your UI components are, a poorly structured navigation system will undermine the entire user experience.
When building apps with Rork Max, the foundation is Expo Router and React Navigation. For simple apps, the default configuration works fine, but once your app grows beyond 20 screens, maintainability deteriorates rapidly without a solid understanding of navigation design patterns.
We'll work through nested structure design, custom screen transitions, auth flow branching, shared element transitions, and performance optimization. Rather than just listing features, I'll center the discussion on the pitfalls I've hit in production apps and how I got around them.
Expo Router uses file-system-based routing where the file structure inside the app/ directory directly maps to the route structure. A deep understanding of this system is the first step toward advanced navigation design.
Route Groups and Layout Hierarchy
In large-scale apps, organizing screens into logical groups is essential. Expo Router's "route groups" (directories wrapped in parentheses) let you structure screens without affecting the URL path.
Three key benefits define this pattern. First, authentication screens, main screens, and modals are managed as independent groups. Second, each group has its own layout (Navigator), cleanly separating navigation responsibilities. Third, the parenthesized portions don't appear in URL paths, so deep links remain clean and natural.
Dynamic Routes and Catch-All Routes
For content-driven apps, dynamic route design is critical.
// app/(tabs)/home/[id].tsx — Dynamic route implementationimport { useLocalSearchParams, Stack } from 'expo-router';import { useQuery } from '@tanstack/react-query';export default function PostDetail() { const { id } = useLocalSearchParams<{ id: string }>(); const { data: post, isLoading } = useQuery({ queryKey: ['post', id], queryFn: () => fetchPost(id), }); return ( <> {/* Dynamically set the header title */} <Stack.Screen options={{ title: post?.title ?? 'Loading...', headerBackTitle: 'Home', }} /> {/* Screen content */} </> );}// app/[...missing].tsx — 404 catch-all// Fallback for routes that don't existimport { Link, Stack } from 'expo-router';import { View, Text, StyleSheet } from 'react-native';export default function NotFoundScreen() { return ( <> <Stack.Screen options={{ title: 'Page Not Found' }} /> <View style={styles.container}> <Text style={styles.title}>404</Text> <Text style={styles.message}> The page you're looking for doesn't exist. </Text> <Link href="/" style={styles.link}> Return to Home </Link> </View> </> );}
✦
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
✦Guaranteeing a root in the stack on deep-link entry cut navigation crashes from 18 to 2 per month
✦lazy / freezeOnBlur on tabs trimmed first paint of the wallpaper grid from 1,100ms to 480ms
✦Working code for six production patterns: double-push guard, focus-based tracking, whitelist state persistence, and more
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.
The most complex navigation challenge in production apps is branching based on authentication state. In Rork Max, you can implement declarative auth guards by combining Expo Router's redirect with useSegments.
Auth Context and Route Guard
// contexts/AuthContext.tsx — Authentication state managementimport React, { createContext, useContext, useEffect, useState } from 'react';import { useRouter, useSegments, useRootNavigationState } from 'expo-router';import * as SecureStore from 'expo-secure-store';type AuthState = { user: User | null; isLoading: boolean; signIn: (email: string, password: string) => Promise<void>; signOut: () => Promise<void>;};const AuthContext = createContext<AuthState | null>(null);export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const segments = useSegments(); const navigationState = useRootNavigationState(); // Initialize auth state useEffect(() => { const restoreSession = async () => { try { const token = await SecureStore.getItemAsync('auth_token'); if (token) { const userData = await verifyToken(token); setUser(userData); } } catch (error) { console.warn('Session restoration failed:', error); await SecureStore.deleteItemAsync('auth_token'); } finally { setIsLoading(false); } }; restoreSession(); }, []); // Navigation guard — redirect on auth state changes useEffect(() => { if (isLoading || !navigationState?.key) return; const inAuthGroup = segments[0] === '(auth)'; if (!user && !inAuthGroup) { // Not authenticated → redirect to login router.replace('/(auth)/login'); } else if (user && inAuthGroup) { // Authenticated → redirect to main screen router.replace('/(tabs)/home'); } }, [user, segments, isLoading, navigationState?.key]); const signIn = async (email: string, password: string) => { const { token, user: userData } = await authenticateUser(email, password); await SecureStore.setItemAsync('auth_token', token); setUser(userData); // The guard in useEffect automatically handles the redirect }; const signOut = async () => { await SecureStore.deleteItemAsync('auth_token'); setUser(null); // The guard in useEffect automatically handles the redirect }; return ( <AuthContext.Provider value={{ user, isLoading, signIn, signOut }}> {children} </AuthContext.Provider> );}export const useAuth = () => { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within AuthProvider'); return context;};
Integrating Onboarding Flows
When displaying onboarding screens for new users, an additional branching layer is needed in the auth flow.
The beauty of this design is that auth logic is centralized in a single location. Individual screen components don't need to worry about authentication state, and new screens are automatically protected simply by placing them in the appropriate route group.
Custom Screen Transition Animations
While default screen transitions are smooth, implementing custom animations that match your app's brand creates a professional impression.
The critical factor in animation design is the Easing.bezier() curve selection. Material Design 3 motion guidelines recommend (0.2, 0.9, 0.3, 1.0) (deceleration) for entering and (0.4, 0.0, 0.2, 1.0) (acceleration) for exiting. This combination produces natural, satisfying transitions.
Shared Element Transitions
The smooth movement of elements from a list screen to a detail screen (shared element transitions) dramatically enhances user experience. Here's how to implement them using React Native Reanimated.
There are several important points to keep in mind when implementing shared element transitions.
First, sharedTransitionTag must be unique across the entire app. Always include the item ID in list elements. If the same tag exists on multiple screens, you'll get unexpected behavior.
Second, from a performance perspective, when rendering large numbers of items in a FlatList, configure initialNumToRender and windowSize appropriately to prevent off-screen items from unnecessarily registering transition tags.
Third, on Android, shared element transition support can be partial in some cases, so it's wise to prepare fallback animations with platform detection.
Advanced Tab and Drawer Combination Patterns
Conditional Tab Bar Visibility
Hiding the tab bar on certain screens while showing it on others is a common pattern.
When implementing state persistence, keep security in mind. If you're saving route information for screens that require authentication, consider using expo-secure-store. Also, include a version key in the storage key to prevent crashes when route structures change between app versions.
Navigation Performance Optimization
As screen count grows, navigation performance can become an issue. Here are techniques to keep things fast.
// hooks/usePrefetchRoute.ts — Screen data prefetchingimport { useQueryClient } from '@tanstack/react-query';import { useCallback } from 'react';export function usePrefetchRoute() { const queryClient = useQueryClient(); const prefetchPost = useCallback( (postId: string) => { // Prefetch data before the user navigates queryClient.prefetchQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), staleTime: 5 * 60 * 1000, // Valid for 5 minutes }); }, [queryClient] ); return { prefetchPost };}// Usage: prefetch on list item onPressInfunction PostListItem({ post }: { post: Post }) { const { prefetchPost } = usePrefetchRoute(); const router = useRouter(); return ( <Pressable onPressIn={() => prefetchPost(post.id)} // Prefetch on touch start onPress={() => router.push(`/(tabs)/home/${post.id}`)} style={styles.listItem} > <Text>{post.title}</Text> </Pressable> );}
By starting the prefetch on onPressIn (the moment the finger touches) and navigating on onPress (when the finger lifts), those few dozen milliseconds make a significant difference in perceived speed.
Memory Management and Navigator Optimization
// app/(tabs)/_layout.tsx — Memory-efficient configuration<Tabs screenOptions={{ // Keep screens mounted in memory instead of unmounting // Makes tab switching faster at the cost of memory lazy: true, // Don't mount screens until first access // freezeOnBlur prevents re-renders on inactive screens freezeOnBlur: true, }}/>
For apps that push many screens onto a stack, consider using the detachInactiveScreens option. Detaching native views of hidden screens reduces memory consumption. For state management approaches, Rork App State Management Patterns Complete Guide is also an excellent reference.
Deep Link Integration Design
In advanced navigation architectures, maintaining consistency with deep links is crucial. Expo Router's file-based routing makes deep link configuration straightforward, but combining it with auth guards requires careful design.
// Deep link + auth guard integration pattern// Add to contexts/AuthContext.tsxconst [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);useEffect(() => { if (isLoading || !navigationState?.key) return; const inAuthGroup = segments[0] === '(auth)'; if (!user && !inAuthGroup) { // Save the deep link before redirecting to auth const currentPath = '/' + segments.join('/'); if (currentPath !== '/(auth)/login') { setPendingDeepLink(currentPath); } router.replace('/(auth)/login'); } else if (user && inAuthGroup) { // After authentication, navigate to the saved deep link if (pendingDeepLink) { router.replace(pendingDeepLink); setPendingDeepLink(null); } else { router.replace('/(tabs)/home'); } }}, [user, segments, isLoading, navigationState?.key]);
Six things the docs won't tell you — lessons from production
So far we've covered design patterns, but once you build navigation into an app that gets opened every day, you keep hitting walls that aren't in the documentation. Here are six I worked through one by one — reading Crashlytics logs and user reports — while running my wallpaper, calming-tone, and intention-style apps.
1. On deep-link entry, an empty stack crashes "back"
When a notification or deep link drops the user straight into a screen, and that screen is the only thing on the navigation stack, pressing "back" empties the stack: Android terminates the app, iOS jumps somewhere unexpected. In my wallpaper app, this accounted for the majority of navigation crashes.
The fix is to guarantee a parent root sits one level below on deep-link arrival.
// app/_layout.tsx — guarantee a root exists on deep-link arrivalimport { useEffect } from "react";import { router, useRootNavigationState, useSegments } from "expo-router";export function useEnsureRootStack() { const navState = useRootNavigationState(); const segments = useSegments(); useEffect(() => { if (!navState?.key) return; const landedDeep = segments.length > 1; // landed deeper than the root if (landedDeep && !router.canGoBack()) { // re-seat the root (/), then go to where we are, so "back" has a target const here = "/" + segments.join("/"); router.replace("/"); router.push(here); } }, [navState?.key, segments]);}
The key is to re-seat the root only when router.canGoBack() is false — i.e. there's nowhere to go back to. Doing it unconditionally duplicates history on normal launches. This single change took navigation crashes in my app's Crashlytics from 18 down to 2 per month.
2. Turning off lazy tab mounting quietly slows first paint
Expo Router's Tabs try to pre-render every tab by default. If your tab layout includes a heavy first paint like a wallpaper grid, the app tries to render all tabs at launch, and the time to that first visible screen gets noticeably worse.
On my wallpaper app (Pixel 4a hardware), lazy: true cut first tab paint from 1,100ms to 480ms, and freezeOnBlur killed wasted re-renders on background tabs. The lower-end the device, the more it helps.
3. Shared element transitions break on low-end devices
Shared element transitions look great, but they drop frames for users who've enabled reduce-motion and on older hardware — which becomes the source of "the screen makes me dizzy" reports. I switch animations on or off based on the device and accessibility settings.
import { AccessibilityInfo, Platform } from "react-native";export async function shouldAnimateShared(): Promise<boolean> { // respect the OS "reduce motion" setting if (await AccessibilityInfo.isReduceMotionEnabled()) return false; // fall back to a cross-fade on older Android const lowEnd = Platform.OS === "android" && Number(Platform.Version) <= 29; return !lowEnd;}
After adding this fallback, detail-screen transitions on the Pixel 4a improved from 42fps to 58fps, and the "dizzy" reports went to zero. I'd rather it never break across devices than look flashy on the newest ones.
4. Don't include auth routes in navigation state persistence
Saving and restoring the whole navigation state is convenient, but if you persist screens that require authentication, a restart after logout restores the user into a protected screen. I made exactly this mistake early on: a profile screen the user had logged out of would open on launch.
The fix is a whitelist.
const PERSIST_WHITELIST = ["(tabs)", "wallpaper", "favorites"];export function sanitizeNavState(state: any) { if (!state?.routes) return state; // drop auth-required routes and modals from what we persist return { ...state, routes: state.routes.filter((r: any) => PERSIST_WHITELIST.includes(r.name)), };}
Stating "what to save" explicitly with a whitelist means you won't have an accident when you add a new protected screen. A blacklist (exclude list) leaks any screen you forget to add.
5. Tapping a modal twice causes a double push
A heavy modal like a checkout screen has a brief delay before it appears. If the user taps again in that window, the same screen gets pushed twice. In my apps I was getting about seven reports a month of "the purchase screen opened twice."
For any "you don't want this pressed twice" path — checkout, login — I always route through this. After adding it, the double-display reports went to zero.
6. Track screen views on focus, not on mount
If you measure screen views in useEffect (on mount), you miss re-entries when the user switches tabs or comes back from a modal. Moving it to useFocusEffect records correctly every time focus lands.
import { useFocusEffect } from "expo-router";import { useCallback } from "react";export function useScreenView(name: string) { useFocusEffect( useCallback(() => { analytics.logScreenView(name); // measure on focus, not on mount }, [name]) );}
Back when I tracked on mount, a wallpaper app where users hop between tabs lost about 23% of its screen-view events. After moving to useFocusEffect, that dropped to 3%, and I could finally see accurately which screens were actually being viewed.
A single next step
Navigation is the kind of foundation you rarely touch once it's built, so the early work pays off. If you change just one thing today, wrap a "you don't want this pressed twice" path — checkout, login — with the useSafeNavigate hook above. Crash and double-charge reports usually trace back to the absence of that one line.
If you run something at a similar scale on your own, I hope this helps with a small next step tomorrow. Thank you for reading.
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.