Navigation with Expo Router
Complete guide to navigation using Expo Router with typed routes and route groups in Caracal.
Expo Router is a file-based routing library for React Native and web apps built with Expo. It brings the simplicity of file-based routing (like Next.js) to React Native, making navigation intuitive and type-safe.
Overview
Caracal uses Expo Router with the following features enabled:
- Typed Routes: Compile-time route validation and autocomplete
- Route Groups: Organize routes without affecting URL structure
- Deep Linking: Automatic deep link handling
- Shared Routes: Common layouts across multiple screens
- Modal Presentation: Native modal navigation
Key Benefits
- Type-safe navigation: Autocomplete and compile-time errors for route names
- File-based routing: Routes are defined by file structure in
src/app/ - No manual configuration: Router automatically detects screens
- Deep linking by default: Every screen is accessible via URL
Project Structure
Caracal organizes routes using route groups — folders wrapped in parentheses that don't appear in the URL.
src/app/
├── _layout.tsx # Root layout (providers, theme)
├── +not-found.tsx # 404 screen
├── (home)/ # Main app (tab navigation)
│ ├── _layout.tsx # Tab navigator
│ ├── index.tsx # Home tab (/)
│ ├── settings.tsx # Settings tab (/settings)
│ ├── style.tsx # Style tab (/style)
│ └── onboarding.tsx # Onboarding screen (/onboarding)
└── (components)/ # Component showcases (30 screens)
├── _layout.tsx # Stack navigator
├── index.tsx # Showcase list (/components)
├── button.tsx # Button showcase (/components/button)
├── text-field.tsx # TextField showcase (/components/text-field)
└── [28 more...] # Other component showcasesRoute Groups Explained
(home) Group: Main application with bottom tab navigation
- Contains the core app experience
- Uses
Tabslayout for bottom navigation - URL path:
/(no "home" in URL)
(components) Group: Component showcase and documentation
- 30 interactive component examples
- Uses
Stacklayout for hierarchical navigation - URL path:
/components/...
Route groups (folders with parentheses) organize your code without affecting URLs. Both (home)/index.tsx and (components)/index.tsx are valid routes.
Root Layout
The root layout (src/app/_layout.tsx) wraps the entire app with providers and configures global navigation settings.
Key responsibilities:
- Provider hierarchy (Theme, HeroUI, API, Keyboard, etc.)
- Splash screen handling
- Font loading
- Global navigation configuration
- Status bar styling
Tab Navigation
The (home) group uses tab navigation for the main app experience.
Tab Configuration:
- Home Tab: Main feed or dashboard (
index.tsx) - Settings Tab: App settings and preferences (
settings.tsx) - Style Tab: Theme and appearance settings (
style.tsx)
Each tab can define:
title: Tab labelheaderShown: Show/hide headertabBarIcon: Custom icon componenttabBarTestID: Test identifier
Stack Navigation
The (components) group uses stack navigation for hierarchical screens.
Stack Features:
- Back navigation with native transitions
- Custom headers with
headerShown,headerTitle, etc. - Modal presentation with
presentation: 'modal' - Card animations and gestures
Typed Routes
Caracal has typed routes enabled in app.config.ts:
experiments: {
typedRoutes: true,
}This generates TypeScript types for all routes, providing autocomplete and compile-time validation.
Navigation with Type Safety
import { router } from 'expo-router';
// ✅ Type-safe navigation (autocomplete works)
router.push('/(home)/settings');
router.push('/(components)/button');
router.navigate('/onboarding');
// ❌ Compile error: route doesn't exist
router.push('/non-existent-route'); // TypeScript error
// Dynamic routes with params
router.push({
pathname: '/(components)/[id]',
params: { id: 'button' }
});Link Component
import { Link } from 'expo-router';
export function MyComponent() {
return (
<>
{/* Type-safe href */}
<Link href="/(home)/settings">Go to Settings</Link>
{/* With params */}
<Link
href={{
pathname: '/(components)/[id]',
params: { id: 'button' }
}}
>
View Button Showcase
</Link>
{/* Replace instead of push */}
<Link href="/(home)/" replace>
Reset to Home
</Link>
</>
);
}Navigation Hooks
Expo Router provides several hooks for navigation and route information.
useRouter
Imperative navigation API.
import { useRouter } from 'expo-router';
export function MyScreen() {
const router = useRouter();
return (
<Button
onPress={() => {
// Push new screen
router.push('/(home)/settings');
// Navigate (replace if already in stack)
router.navigate('/onboarding');
// Go back
router.back();
// Replace current screen
router.replace('/(home)/');
// Dismiss modal
router.dismiss();
// Can go back?
if (router.canGoBack()) {
router.back();
}
}}
>
Navigate
</Button>
);
}usePathname
Get the current pathname.
import { usePathname } from 'expo-router';
export function MyComponent() {
const pathname = usePathname();
console.log(pathname); // "/(home)/settings" or "/components/button"
return <Text>Current path: {pathname}</Text>;
}useSegments
Get route segments as an array.
import { useSegments } from 'expo-router';
export function MyComponent() {
const segments = useSegments();
console.log(segments); // ["(home)", "settings"] or ["(components)", "button"]
const isComponentShowcase = segments[0] === '(components)';
return <Text>Is Showcase: {isComponentShowcase}</Text>;
}useLocalSearchParams & useGlobalSearchParams
Access route parameters.
import { useLocalSearchParams, useGlobalSearchParams } from 'expo-router';
export function ComponentShowcase() {
// Local params (from this route only)
const local = useLocalSearchParams<{ id: string }>();
// Global params (from entire URL)
const global = useGlobalSearchParams<{ theme?: string }>();
return (
<View>
<Text>Component ID: {local.id}</Text>
<Text>Theme: {global.theme || 'default'}</Text>
</View>
);
}useFocusEffect
Run side effects when screen focuses/unfocuses.
import { useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
export function MyScreen() {
useFocusEffect(
useCallback(() => {
// Screen focused - fetch fresh data
console.log('Screen focused');
return () => {
// Screen unfocused - cleanup
console.log('Screen unfocused');
};
}, [])
);
return <View>...</View>;
}Deep Linking
Every route in Expo Router is automatically deep-linkable. No configuration required.
URL Schemes
Caracal is configured with URL schemes in app.config.ts:
// app.config.ts
export default {
scheme: 'caracal',
// ...
}Available schemes:
caracalApp://— Custom scheme (all environments)exp+caracalapp://— Expo Go scheme (development)https://yourapp.com/— Universal links (production)
Deep Link Examples
# Open settings screen
caracalApp://settings
# Open component showcase
caracalApp://components/button
# With query parameters
caracalApp://settings?theme=dark
# Universal link (production)
https://yourapp.com/components/text-fieldTesting Deep Links
iOS Simulator:
xcrun simctl openurl booted "caracalApp://settings"Android Emulator:
adb shell am start -W -a android.intent.action.VIEW -d "caracalApp://settings"Expo Go:
npx uri-scheme open "exp+caracalapp://components/button" --ios
npx uri-scheme open "exp+caracalapp://components/button" --androidHandling Deep Links
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
export function useDeepLinkHandler() {
useEffect(() => {
// Get initial URL (app opened from deep link)
Linking.getInitialURL().then((url) => {
if (url) {
console.log('App opened with:', url);
// Handle deep link
}
});
// Listen for deep links while app is running
const subscription = Linking.addEventListener('url', ({ url }) => {
console.log('Deep link received:', url);
// Handle deep link
});
return () => subscription.remove();
}, []);
}With Expo Router, you typically don't need to manually parse deep links. The router automatically navigates to the correct screen based on the URL.
Modal Presentation
Present screens as modals using the presentation option.
Stack Modal
// src/app/(home)/_layout.tsx
<Stack.Screen
name="modal-screen"
options={{
presentation: 'modal',
headerShown: true,
headerTitle: 'Modal Screen',
}}
/>Programmatic Modal
import { router } from 'expo-router';
export function MyComponent() {
return (
<Button
onPress={() => {
router.push({
pathname: '/modal-screen',
});
}}
>
Open Modal
</Button>
);
}
// In modal screen
export function ModalScreen() {
const router = useRouter();
return (
<View>
<Button onPress={() => router.dismiss()}>
Close Modal
</Button>
</View>
);
}Full Screen Modal
<Stack.Screen
name="full-screen-modal"
options={{
presentation: 'fullScreenModal',
headerShown: false,
}}
/>Navigation Guards
Protect routes with authentication checks.
// src/app/_layout.tsx
import { useAuth } from '@/lib/auth';
import { Redirect, Stack } from 'expo-router';
export default function RootLayout() {
const status = useAuth.use.status();
const token = useAuth.use.token();
// Show splash while checking auth
if (status === 'idle') {
return <SplashScreen />;
}
// Redirect to onboarding if not authenticated
if (status === 'signOut' && !token) {
return <Redirect href="/onboarding" />;
}
return <Stack>...</Stack>;
}Common Patterns
Back Navigation with Fallback
import { router } from 'expo-router';
function handleBack() {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/(home)/');
}
}Conditional Navigation
function handleLogin() {
if (isFirstTimeUser) {
router.replace('/onboarding');
} else {
router.replace('/(home)/');
}
}Reset Navigation Stack
// Clear stack and go to home
router.dismissAll();
router.replace('/(home)/');Passing Data Between Screens
Via URL params (recommended for simple data):
// Navigate with params
router.push({
pathname: '/(components)/button',
params: { variant: 'solid', color: 'primary' }
});
// Access params
const { variant, color } = useLocalSearchParams<{
variant: string;
color: string;
}>();Via global state (for complex data):
// Use Zustand
const { setSelectedItem } = useStore();
function handleNavigate(item: Item) {
setSelectedItem(item);
router.push('/item-details');
}Tab Bar Customization
<Tabs
screenOptions={{
tabBarActiveTintColor: '#000',
tabBarInactiveTintColor: '#666',
tabBarStyle: {
backgroundColor: '#fff',
borderTopColor: '#e5e5e5',
},
tabBarLabelStyle: {
fontSize: 12,
fontFamily: 'Inter',
},
headerShown: false,
}}
>
{/* tabs */}
</Tabs>Hide Tab Bar on Specific Screens
<Stack.Screen
name="details"
options={{
tabBarStyle: { display: 'none' },
}}
/>Header Customization
<Stack.Screen
name="settings"
options={{
headerShown: true,
headerTitle: 'Settings',
headerTitleAlign: 'center',
headerStyle: {
backgroundColor: '#f5f5f5',
},
headerTintColor: '#000',
headerShadowVisible: false,
headerBackTitle: 'Back',
}}
/>Custom Header Component
<Stack.Screen
name="profile"
options={{
header: () => <CustomHeader />,
}}
/>Troubleshooting
Routes Not Appearing
Problem: New screen not showing in app.
Solution:
- Ensure file is in
src/app/directory - File must export default component
- Restart dev server (
pnpm startthenr) - Clear Metro cache:
pnpm start --clear
Type Errors on Routes
Problem: TypeScript errors on route names.
Solution:
- Types generate on save — edit any file in
src/app/ - Restart TypeScript server in VS Code
- Check
expo-env.d.tsis in your project root
Deep Links Not Working
Problem: Deep links not opening app.
Solution:
- Ensure
schemeis configured inapp.config.ts - Rebuild app after config changes:
pnpm prebuild - Test with correct scheme:
caracalApp://(nothttp://) - Check URL format matches file structure
Tab Bar Flickering
Problem: Tab bar flickers when navigating.
Solution:
<Tabs
screenOptions={{
lazy: false, // Render all tabs immediately
}}
>
{/* tabs */}
</Tabs>Best Practices
- Use typed routes: Always use TypeScript autocomplete for route names
- Route groups: Organize related screens in route groups
- Lazy loading: Use
lazy: truefor tabs with heavy content - Deep link testing: Test all routes with deep links in development
- Navigation guards: Protect authenticated routes at root layout
- Error boundaries: Add error boundaries to catch navigation errors
- Back button: Always provide a way to go back (header or custom button)
- Modal dismissal: Ensure modals can be dismissed with gestures or button
Additional Resources
- Expo Router Documentation
- Expo Router API Reference
- Typed Routes
- Deep Linking Guide
- React Navigation (underlying library)
Component Showcase Routes
Caracal includes 30 component showcase routes in the (components) group — interactive examples demonstrating HeroUI Native components.
View the showcase:
- Navigate to Settings → Component Showcase
- Or use deep link:
caracalApp://components - Or see Component Showcase documentation
All showcase routes are listed in Project Structure.