Caracal Starter
Guides

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 showcases

Route Groups Explained

(home) Group: Main application with bottom tab navigation

  • Contains the core app experience
  • Uses Tabs layout for bottom navigation
  • URL path: / (no "home" in URL)

(components) Group: Component showcase and documentation

  • 30 interactive component examples
  • Uses Stack layout 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 label
  • headerShown: Show/hide header
  • tabBarIcon: Custom icon component
  • tabBarTestID: 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.

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' }
});
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>
    </>
  );
}

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)
# 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-field

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" --android
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.

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,
  }}
/>

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:

  1. Ensure file is in src/app/ directory
  2. File must export default component
  3. Restart dev server (pnpm start then r)
  4. Clear Metro cache: pnpm start --clear

Type Errors on Routes

Problem: TypeScript errors on route names.

Solution:

  1. Types generate on save — edit any file in src/app/
  2. Restart TypeScript server in VS Code
  3. Check expo-env.d.ts is in your project root

Problem: Deep links not opening app.

Solution:

  1. Ensure scheme is configured in app.config.ts
  2. Rebuild app after config changes: pnpm prebuild
  3. Test with correct scheme: caracalApp:// (not http://)
  4. 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

  1. Use typed routes: Always use TypeScript autocomplete for route names
  2. Route groups: Organize related screens in route groups
  3. Lazy loading: Use lazy: true for tabs with heavy content
  4. Deep link testing: Test all routes with deep links in development
  5. Navigation guards: Protect authenticated routes at root layout
  6. Error boundaries: Add error boundaries to catch navigation errors
  7. Back button: Always provide a way to go back (header or custom button)
  8. Modal dismissal: Ensure modals can be dismissed with gestures or button

Additional Resources

Component Showcase Routes

Caracal includes 30 component showcase routes in the (components) group — interactive examples demonstrating HeroUI Native components.

View the showcase:

All showcase routes are listed in Project Structure.

On this page