Skip to main content

Expo Router Overview

Sovran uses Expo Router for navigation, which provides file-based routing similar to Next.js. Routes are defined by the file structure in the app/ directory. The app follows a three-level navigation structure:
┌─────────────────────────────────────┐
│  Root Stack (_layout.tsx)           │
│  ┌───────────────────────────────┐  │
│  │  Drawer ((drawer)/_layout.tsx)│  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Tabs ((tabs)/_layout)  │  │  │
│  │  │  - Feed                 │  │  │
│  │  │  - Wallet               │  │  │
│  │  │  - Payments             │  │  │
│  │  │  - Explore              │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
│  + Modal flows (send, receive, etc) │
└─────────────────────────────────────┘

Root Layout

app/_layout.tsx:1-334 Key responsibilities:
  • Initialize provider hierarchy (see Architecture Overview)
  • Configure Stack navigator with modal presentations
  • Handle theme changes via React key prop
  • Manage profile switching by remounting inner providers
Screen options:
<Stack
  screenOptions={{
    headerShown: false,
    gestureEnabled: true,
    contentStyle: { backgroundColor: contentBackgroundColor }
  }}
>
  <Stack.Screen name="(drawer)" />
  {MODAL_SCREENS.map(screen => (
    <Stack.Screen key={screen.name} name={screen.name} options={...} />
  ))}
</Stack>

Drawer Layout

app/(drawer)/_layout.tsx:1-454 The drawer provides the main app navigation with a custom drawer content component. Features:
  • Profile switcher at the top (switch between multiple wallets)
  • User avatar and QR code (tap to view profile)
  • Menu items: Feed, Wallet, Payments, Settings
  • 82% screen width (max 320px)
  • Slide animation with 60% black overlay
Profile switching: app/(drawer)/_layout.tsx:89-119 Menu structure:
const MENU_ITEMS = [
  { icon: 'mingcute:home-4-fill', label: 'Feed', route: '(drawer)/(tabs)/feed' },
  { icon: 'fluent:wallet-20-filled', label: 'Wallet', route: '(drawer)/(tabs)/index' },
  { icon: 'fluent:arrow-swap-16-filled', label: 'Payments', route: '(drawer)/(tabs)/payments' },
  { icon: 'material-symbols:settings-rounded', label: 'Settings', route: 'settings-pages' }
];

Tab Layout

app/(drawer)/(tabs)/_layout.tsx:1-166 The tabs use conditional rendering based on device capabilities:

iOS 26+ with Liquid Glass

app/(drawer)/(tabs)/_layout.tsx:38-98 Uses Expo55NativeTabs for native iOS tab bar with blur effects.

Android & iOS (below version 26)

app/(drawer)/(tabs)/_layout.tsx:102-164 Fallback to standard Tabs with BlurView background. Tab structure:
  • Feed (feed/) - Social feed and stories (house icon)
  • Payments (payments/) - Contacts and messages (arrow.up.arrow.down icon)
  • Wallet (index/) - Balances and transactions (wallet.bifold icon)
  • Explore (explore/) - AI chat, maps, pending ecash (paperplane icon)
The wallet tab is at index/ (not wallet/) for backward compatibility with deep links.

Route Groups

Expo Router uses parentheses () for route groups that don’t appear in URLs:

Flow-Based Groups

These groups represent multi-step user flows:
GroupPurposeScreens
(send-flow)Send paymentscurrency → mintSelect → sendToken → meltQuote
(receive-flow)Receive paymentsreceive → currency → mintQuote → receiveToken
(mint-flow)Mint managementadd → list → info → reviews → rebalancePlan
(user-flow)User profilesprofile → share → userMessages → thread
(map-flow)BTCMap integrationindex (map) → detail (merchant)
(filter-flow)Transaction filtersfilters
(transactions-flow)Transaction detailstransactions → swap → sendToken → receiveToken

Special Layouts

Each route group has a _layout.tsx that configures:
  • Presentation style (modal, formSheet, card)
  • Header options (title, background, blur)
  • Gesture handling
  • Animation settings
Example from send flow:
// app/(send-flow)/_layout.tsx
export default function SendFlowLayout() {
  return (
    <Stack
      screenOptions={{
        presentation: 'modal',
        headerShown: true,
        headerBlurEffect: 'regular',
        headerTransparent: true,
      }}
    >
      <Stack.Screen name="currency" options={{ title: 'Amount' }} />
      <Stack.Screen name="mintSelect" options={{ title: 'Select Mint' }} />
      <Stack.Screen name="sendToken" options={{ title: 'Send' }} />
      <Stack.Screen name="meltQuote" options={{ title: 'Pay Lightning' }} />
    </Stack>
  )
}

Type-Safe Navigation

import { router } from 'expo-router';

// Navigate to a screen with params
router.navigate({
  pathname: '/(send-flow)/sendToken' as any,
  params: {
    amount: '1000',
    selectedMintUrl: 'https://mint.example.com',
  }
});

// Replace current screen
router.replace({
  pathname: '/(receive-flow)/receiveToken' as any,
  params: { token: 'cashuA...' }
});

// Go back
router.back();
The as any cast is required because Expo Router’s TypeScript types don’t fully support dynamic route groups yet. This is safe as long as the pathname string matches an actual route file.

Deep Linking

app.json configures deep link schemes:
{
  "expo": {
    "scheme": ["sovran", "cashu"]
  }
}
Deep links are handled by useDeeplink hook:
// hooks/useDeeplink.ts
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
import { router } from 'expo-router';

export function useDeeplink() {
  useEffect(() => {
    const handleUrl = (event: { url: string }) => {
      const { hostname, path, queryParams } = Linking.parse(event.url);
      
      // cashu://token?token=cashuA...
      if (hostname === 'token') {
        router.navigate({
          pathname: '/(receive-flow)/receiveToken',
          params: { token: queryParams.token }
        });
      }
    };
    
    Linking.addEventListener('url', handleUrl);
    return () => Linking.removeEventListener('url', handleUrl);
  }, []);
}
app/_layout.tsx:216-220 Modal screens are configured in config/modalScreens.ts:
interface ModalConfig {
  name: string;
  title?: string;
  options?: {
    presentation?: 'modal' | 'formSheet' | 'card';
    headerShown?: boolean;
    headerTransparent?: boolean;
    // ... other options
  };
}

export const MODAL_SCREENS: ModalConfig[] = [
  {
    name: '(send-flow)',
    options: { presentation: 'modal', headerShown: false }
  },
  {
    name: 'camera',
    title: 'Scan QR Code',
    options: { presentation: 'fullScreenModal' }
  },
  // ... more screens
];

Route Parameters

Access route parameters using useLocalSearchParams:
import { useLocalSearchParams } from 'expo-router';

export default function SendTokenScreen() {
  const { amount, selectedMintUrl } = useLocalSearchParams<{
    amount?: string;
    selectedMintUrl?: string;
  }>();
  
  return (
    <View>
      <Text>Amount: {amount}</Text>
      <Text>Mint: {selectedMintUrl}</Text>
    </View>
  );
}

Payment Flow Routing

The payment flows use intelligent routing based on available data: hooks/coco/useProcessPaymentString.ts:216-331 Payment request routing logic:
MintsAmountValidFlow
NoNoAnycurrency.tsx (pick mint + amount) → SendTokenScreen
NoYes1SendTokenScreen directly
NoYes2+mintSelect.tsx → SendTokenScreen
YesNo1currency.tsx (amount only) → SendTokenScreen
YesNo2+currency.tsx (pick from allowed + amount) → SendTokenScreen
YesYes1SendTokenScreen (PR mode) directly
YesYes2+mintSelect.tsx → SendTokenScreen (PR mode)
This minimizes user friction by skipping screens when data is already available.

Screen Transitions

Sovran uses hero transitions for seamless animations between screens:
import { HeroTransition } from '@/components/ui/hero-transition';

// Source screen
<HeroTransition.View transitionId="wallet-card">
  <WalletCard />
</HeroTransition.View>

// Destination screen
<HeroTransition.View transitionId="wallet-card">
  <WalletDetailView />
</HeroTransition.View>
Example: Tapping the wallet health card on explore page transitions to full health modal with shared element animation.

Best Practices

Screens in app/ should be orchestration-only. Move business logic to hooks/, data fetching to stores/, and UI to components/blocks/.
// Good ✅
export default function SendTokenScreen() {
  const { amount } = useLocalSearchParams();
  const { send } = useSendWithHistory();
  
  return <SendTokenView amount={amount} onSend={send} />;
}

// Bad ❌
export default function SendTokenScreen() {
  const [loading, setLoading] = useState(false);
  // ... 200 lines of business logic
}
When navigating from a form screen to another form screen, use router.replace() to avoid stack buildup:
// User enters amount, then selects mint
router.replace({
  pathname: '/(send-flow)/mintSelect',
  params: { amount: '1000' }
});
Route parameters are always strings or undefined. Validate and parse them at the top of the component:
export default function SendTokenScreen() {
  const params = useLocalSearchParams<{ amount?: string }>();
  
  const amount = useMemo(() => {
    const parsed = parseInt(params.amount ?? '0', 10);
    return isNaN(parsed) ? 0 : parsed;
  }, [params.amount]);
  
  if (amount === 0) {
    return <ErrorView message="Invalid amount" />;
  }
  
  // ... rest of component
}
Always consider what happens when the user goes back. Use router.canGoBack() to check if there’s a previous screen:
const handleBack = () => {
  if (router.canGoBack()) {
    router.back();
  } else {
    router.replace('/(drawer)/(tabs)/index');
  }
};

State Management

Learn how Zustand stores integrate with navigation

Cashu Integration

See how payment flows interact with Coco manager

Build docs developers (and LLMs) love