The root layout acts as the application's entry point. It initializes providers, error boundaries, and global state synchronization. By wrapping the layout in a React component, you ensure that context providers persist across navigation transitions without remounting.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/providers/theme';
import { ErrorBoundary } from '@/components/error-boundary';
import { queryClient } from '@/lib/query-client';
export default function RootLayout() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ animation: 'fade' }} />
<Stack.Screen name="onboarding" options={{ animation: 'slide_from_right' }} />
</Stack>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
Architecture Rationale: The root layout isolates global state from route-specific logic. Using Stack here establishes the primary navigation container. Error boundaries and query clients are placed at the highest level to prevent context loss during screen transitions. This mirrors React's composition model and ensures providers survive navigation state changes.
Step 2: Implement Route Groups for Context Isolation
Route groups allow logical separation without affecting the URL structure. They are ideal for isolating authentication flows, dashboard modules, and public marketing pages.
// app/(app)/_layout.tsx
import { Tabs } from 'expo-router';
import { useAuth } from '@/hooks/use-auth';
import { Redirect } from 'expo-router';
export default function AppLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
if (!isAuthenticated) return <Redirect href="/onboarding" />;
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="feed" options={{ title: 'Home' }} />
<Tabs.Screen name="analytics" options={{ title: 'Insights' }} />
<Tabs.Screen name="settings" options={{ title: 'Account' }} />
</Tabs>
);
}
Architecture Rationale: Route groups (app) create a logical boundary that does not appear in the URL. The layout file doubles as an auth guard, leveraging declarative redirects instead of conditional root navigator swapping. This eliminates the synchronization bugs common in imperative auth stacks. The Tabs navigator is instantiated only after authentication resolves, keeping the navigation tree lean.
Step 3: Handle Dynamic Parameters with Type Safety
Dynamic routes map directly to file names using bracket notation. TypeScript inference ensures parameter types remain consistent across navigation calls.
// app/(app)/settings/[section].tsx
import { useLocalSearchParams } from 'expo-router';
import { SettingsPanel } from '@/components/settings-panel';
import { SectionType } from '@/types/settings';
export default function SettingsScreen() {
const { section } = useLocalSearchParams<{ section: SectionType }>();
if (!section) {
return <SettingsPanel fallback="general" />;
}
return <SettingsPanel activeSection={section} />;
}
Architecture Rationale: Bracket notation [section] automatically generates type-safe route parameters. useLocalSearchParams extracts and validates the URL segment at runtime. Providing a fallback prevents undefined state crashes when users land on incomplete URLs. This pattern replaces manual parameter parsing and reduces runtime type errors.
Step 4: Optimize Bundle Boundaries
File-based routing automatically splits code at route boundaries. To maximize performance, ensure heavy dependencies are imported inside route files rather than layout files.
// app/(app)/analytics.tsx
import { Suspense, lazy } from 'react';
import { View, ActivityIndicator } from 'react-native';
const ChartEngine = lazy(() => import('@/components/chart-engine'));
export default function AnalyticsScreen() {
return (
<Suspense fallback={<ActivityIndicator size="large" />}>
<ChartEngine />
</Suspense>
);
}
Architecture Rationale: Lazy loading complements automatic route splitting. By deferring heavy visualization libraries until the route is accessed, initial bundle size drops significantly. The Suspense boundary ensures graceful loading states without blocking the navigation thread.
Pitfall Guide
1. Over-Nesting Route Groups
Explanation: Creating excessive route groups like (dashboard)/(charts)/(reports) introduces unnecessary navigator layers. Each group wraps its children in a layout component, increasing render depth and memory overhead.
Fix: Limit route groups to logical boundaries that require distinct navigation contexts (e.g., auth, public, app). Flatten deeply nested structures into single groups with conditional rendering inside _layout.tsx.
2. Ignoring Layout Memoization
Explanation: Layout files re-render whenever navigation state changes. Without memoization, expensive context providers or animation configurations trigger full tree re-renders.
Fix: Wrap layout components in React.memo or use useMemo for static configurations. Keep layout files focused on navigation structure; move data fetching to screen components.
3. Mixing Imperative Navigation with File-Based Expectations
Explanation: Calling navigation.navigate('SomeScreen') with hardcoded strings bypasses TypeScript route inference and breaks when file paths change.
Fix: Use type-safe navigation helpers or rely on router.push('/path/to/screen') with string literals that match file paths. Enable strict TypeScript checking to catch mismatches at compile time.
4. Misconfiguring Dynamic Route Parameters
Explanation: Failing to validate or provide defaults for dynamic segments causes undefined crashes when users access malformed URLs or share incomplete links.
Fix: Always type useLocalSearchParams with explicit interfaces. Implement fallback UI or redirect logic when required parameters are missing. Validate parameters against expected enums or schemas.
5. Bypassing the Layout System for Auth Guards
Explanation: Implementing authentication checks inside individual screens leads to duplicated logic and race conditions where protected content briefly renders before redirecting.
Fix: Centralize auth guards in _layout.tsx files. Use declarative redirects that execute before screen mounting. Keep authentication state in a global store or context that layouts can synchronously read.
6. Assuming Web Parity Means Identical UX
Explanation: File-based routing enables universal apps, but mobile and web users expect different interaction patterns. Forcing identical navigation flows across platforms degrades usability.
Fix: Use platform-specific layout variants or conditional rendering inside _layout.tsx. Leverage Platform.OS to adjust header styles, gesture responders, and transition animations while maintaining shared route structure.
7. Neglecting Bundle Splitting Boundaries
Explanation: Importing heavy libraries in layout files or shared utilities defeats automatic route splitting. The entire bundle loads regardless of which route is accessed.
Fix: Keep layout files lightweight. Move third-party dependencies, charting libraries, and media processors into route-specific files. Use dynamic imports with Suspense for non-critical modules.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New Expo project with web parity requirements | File-based routing (Expo Router) | Automatic deep linking, bundle splitting, and self-documenting structure reduce initial setup time by 40β60% | Lower long-term maintenance; moderate migration cost if legacy code exists |
| Brownfield native app with tight native navigation control | Imperative config (React Navigation) | Preserves existing native bridge integrations and custom gesture handlers without framework constraints | Higher DX overhead; lower risk of breaking native modules |
| Large team dashboard with auth + tabs + nested stacks | File-based routing | Route groups enforce boundaries, reduce config drift, and accelerate onboarding | Slight learning curve; significant reduction in merge conflicts |
| Highly bespoke transitions or custom navigator compositions | Imperative config or hybrid | File-based system falls back to native APIs, but complex gesture orchestration is simpler with direct navigator access | Higher implementation cost; maximum flexibility |
| Universal app requiring SEO and static rendering | File-based routing | Declarative layouts align with static site generation pipelines and meta tag injection patterns | Minimal additional cost; leverages existing web tooling |
Configuration Template
Copy this structure to establish a production-ready routing foundation. Adjust providers and auth logic to match your stack.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { ErrorBoundary } from '@/components/error-boundary';
import { ThemeProvider } from '@/providers/theme';
import { AuthProvider } from '@/providers/auth';
export default function RootLayout() {
return (
<ErrorBoundary>
<ThemeProvider>
<AuthProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ animation: 'fade' }} />
<Stack.Screen name="onboarding" options={{ animation: 'slide_from_right' }} />
</Stack>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
// app/(app)/_layout.tsx
import { Tabs } from 'expo-router';
import { useAuth } from '@/hooks/use-auth';
import { Redirect } from 'expo-router';
export default function AppLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
if (!isAuthenticated) return <Redirect href="/onboarding" />;
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="feed" options={{ title: 'Home' }} />
<Tabs.Screen name="analytics" options={{ title: 'Insights' }} />
<Tabs.Screen name="settings" options={{ title: 'Account' }} />
</Tabs>
);
}
Quick Start Guide
- Initialize a new Expo project with TypeScript:
npx create-expo-app@latest my-app --template expo-router-ts
- Create the root layout file at
app/_layout.tsx and wrap it with your global providers (theme, auth, query client).
- Define route groups by creating folders with parentheses, e.g.,
app/(app)/_layout.tsx, and configure the navigator type (Stack, Tabs, or Drawer).
- Add screens as files inside route groups. Dynamic routes use bracket notation:
app/(app)/settings/[section].tsx.
- Run
npx expo start and verify navigation, deep linking, and auth guards. Adjust layout configurations as business logic expands.