.claude/skills/expo-router-patterns/SKILL.md
Master Expo Router file-based routing for RidenDine mobile app. Use when: (1) adding new screens/routes, (2) implementing navigation patterns, (3) setting up auth guards, (4) debugging navigation issues, (5) configuring deep links. Key insight: Expo Router uses file-system based routing like Next.js App Router - file structure defines route structure.
npx skillsauth add Ritenoob/ridedine expo-router-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
RidenDine mobile app (React Native/Expo SDK 50) uses Expo Router for navigation. Traditional React Navigation requires manual route configuration. Expo Router uses file-system based routing for:
Use this skill when:
App Directory: apps/mobile/app/
Role-Specific Navigation:
Route Structure:
apps/mobile/app/
├── (auth)/ # Auth group (requires login)
│ ├── _layout.tsx # Auth layout (checks user session)
│ ├── (tabs)/ # Tabbed navigation
│ │ ├── _layout.tsx # Tabs layout (customer role)
│ │ ├── index.tsx # Home tab (browse chefs)
│ │ ├── orders.tsx # Orders tab
│ │ └── profile.tsx # Profile tab
│ ├── chef/ # Chef-specific routes
│ │ ├── _layout.tsx # Chef layout
│ │ ├── orders.tsx # Chef's orders
│ │ └── dishes.tsx # Manage dishes
│ └── driver/ # Driver-specific routes
│ ├── _layout.tsx # Driver layout
│ └── deliveries.tsx # Driver's deliveries
├── login.tsx # Login screen (public)
├── signup.tsx # Signup screen (public)
└── _layout.tsx # Root layout (wraps entire app)
File → Route Mapping:
| File Path | Route URL | Description |
| -------------------------------- | -------------------- | -------------------------- |
| app/index.tsx | / | Landing/home screen |
| app/login.tsx | /login | Login screen |
| app/(auth)/profile.tsx | /profile | Profile (auth required) |
| app/(auth)/(tabs)/orders.tsx | /orders | Orders tab |
| app/(auth)/chef/orders.tsx | /chef/orders | Chef's orders |
| app/chefs/[id].tsx | /chefs/:id | Chef detail (dynamic) |
| app/orders/[orderId]/track.tsx | /orders/:orderId/track | Order tracking |
Route Groups:
(auth) - Requires authentication, enforced by _layout.tsx(tabs) - Tab navigation (bottom tabs)Location: apps/mobile/app/_layout.tsx
Purpose:
Example Implementation:
import { Stack } from 'expo-router';
import { SessionProvider } from '@/lib/SessionContext';
import { CartProvider } from '@/lib/CartContext';
export default function RootLayout() {
return (
<SessionProvider>
<CartProvider>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#fff' },
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="signup" />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
</CartProvider>
</SessionProvider>
);
}
Location: apps/mobile/app/(auth)/_layout.tsx
Purpose: Redirect to login if user is not authenticated
Example Implementation:
import { useEffect } from 'react';
import { Redirect, Stack, useRouter, useSegments } from 'expo-router';
import { useSession } from '@/lib/SessionContext';
export default function AuthLayout() {
const { session, loading } = useSession();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return; // Wait for session check
const inAuthGroup = segments[0] === '(auth)';
if (!session && inAuthGroup) {
// Redirect to login if not authenticated
router.replace('/login');
} else if (session && !inAuthGroup) {
// Redirect to home if already authenticated
router.replace('/(auth)/(tabs)');
}
}, [session, loading, segments]);
if (loading) {
return null; // Or show loading spinner
}
if (!session) {
return <Redirect href="/login" />;
}
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="chef" />
<Stack.Screen name="driver" />
</Stack>
);
}
SessionContext Example:
// apps/mobile/lib/SessionContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { Session } from '@supabase/supabase-js';
import { supabase } from './supabase';
interface SessionContextType {
session: Session | null;
loading: boolean;
}
const SessionContext = createContext<SessionContextType>({
session: null,
loading: true,
});
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
return () => subscription.unsubscribe();
}, []);
return (
<SessionContext.Provider value={{ session, loading }}>
{children}
</SessionContext.Provider>
);
}
export const useSession = () => useContext(SessionContext);
Location: apps/mobile/app/(auth)/(tabs)/_layout.tsx
Purpose: Bottom tab navigation for customer role
Example Implementation:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#FF6B6B',
tabBarInactiveTintColor: '#999',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="orders"
options={{
title: 'Orders',
tabBarIcon: ({ color, size }) => (
<Ionicons name="receipt-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Location: apps/mobile/app/chefs/[id].tsx
Purpose: Chef detail page with dynamic ID
Example Implementation:
import { useLocalSearchParams, Stack } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { supabase } from '@/lib/supabase';
import type { Chef } from '@home-chef/shared';
export default function ChefDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const [chef, setChef] = useState<Chef | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchChef() {
const { data, error } = await supabase
.from('chefs')
.select('*, profiles(name, email)')
.eq('id', id)
.single();
if (error) {
console.error(error);
} else {
setChef(data);
}
setLoading(false);
}
fetchChef();
}, [id]);
if (loading) {
return <ActivityIndicator />;
}
return (
<>
<Stack.Screen options={{ title: chef?.profiles.name || 'Chef' }} />
<View>
<Text>{chef?.profiles.name}</Text>
<Text>{chef?.bio}</Text>
</View>
</>
);
}
Navigation to Dynamic Route:
import { router } from 'expo-router';
// Navigate to chef detail
<TouchableOpacity onPress={() => router.push(`/chefs/${chefId}`)}>
<Text>View Chef</Text>
</TouchableOpacity>
Type-Safe Navigation:
import { router, useRouter, Link } from 'expo-router';
// Method 1: router singleton (anywhere in app)
router.push('/orders');
router.replace('/login'); // Replace current route (no back button)
router.back(); // Go back
router.setParams({ orderId: '123' }); // Update params
// Method 2: useRouter hook (inside components)
function MyComponent() {
const router = useRouter();
const handlePress = () => {
router.push('/orders');
};
}
// Method 3: Link component (declarative)
<Link href="/orders">
<Text>View Orders</Text>
</Link>
// Dynamic routes
router.push(`/chefs/${chefId}`);
router.push({
pathname: '/chefs/[id]',
params: { id: chefId },
});
Problem: Different roles need different navigation structures
Solution: Conditional layout based on user role
Location: apps/mobile/app/(auth)/_layout.tsx
import { useSession } from '@/lib/SessionContext';
import { Redirect } from 'expo-router';
export default function AuthLayout() {
const { session, user } = useSession();
if (!session) {
return <Redirect href="/login" />;
}
// Redirect based on role
if (user?.role === 'chef') {
return <Redirect href="/chef/orders" />;
} else if (user?.role === 'driver') {
return <Redirect href="/driver/deliveries" />;
}
// Default: customer with tabs
return <Redirect href="/(auth)/(tabs)" />;
}
Configuration: apps/mobile/app.json
{
"expo": {
"scheme": "ridendine",
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "ridendine.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"ios": {
"associatedDomains": ["applinks:ridendine.com"]
}
}
}
Deep Link Examples:
| Deep Link | Opens Screen |
| ----------------------------------- | ------------------------ |
| ridendine://chefs/chef-123 | Chef detail |
| ridendine://orders/order-456 | Order detail |
| https://ridendine.com/chefs/chef-123 | Chef detail (universal link) |
Handling Deep Links:
// Expo Router automatically handles deep links based on file structure
// No additional code needed if routes match URL structure
Symptom: App shows "Cannot GET /" on web or blank screen on mobile
Cause: No index.tsx file in app directory
Fix:
apps/mobile/app/index.tsxexport { default } from './(auth)/(tabs)/index';
Symptom: router.push() does nothing or throws error
Cause: File doesn't exist or route group misconfigured
Fix:
(auth) groups don't appear in URLrouter.push('/orders') (not /auth/orders)router.push('/(auth)/orders') (route groups excluded)Symptom: App keeps redirecting between login and protected routes
Cause: Missing loading check or incorrect redirect logic
Fix:
loading is truesegments to avoid unnecessary redirectsrouter.replace() instead of router.push() to avoid back button issuesSymptom: Bottom tabs don't appear
Cause: _layout.tsx not using Tabs component or wrong nesting
Fix:
app/(auth)/(tabs)/_layout.tsx uses <Tabs>screenOptions.tabBarStyle not hiddenManual Testing:
Auth Flow:
Tab Navigation:
Dynamic Routes:
Deep Links:
# iOS Simulator
xcrun simctl openurl booted ridendine://chefs/chef-123
# Android Emulator
adb shell am start -W -a android.intent.action.VIEW -d "ridendine://chefs/chef-123"
apps/mobile/app/development
Integrate Coinbase crypto payments into payment systems. Use when: (1) adding crypto payment support, (2) building onchain features, (3) implementing wallet functionality. Covers Coinbase Commerce (payment processor) vs CDP (developer platform), Server Wallets, Embedded Wallets, and multi-network support.
development
Add Apple Pay and Google Pay to Stripe checkout. Use when: (1) adding mobile wallet payments, (2) improving mobile conversion, (3) implementing one-tap checkout. Stripe Payment Request Button automatically detects device capabilities and shows Apple Pay (Safari/iOS) or Google Pay (Chrome/Android).
development
Master Vercel deployment for RidenDine web and admin Next.js apps. Use when: (1) deploying to production, (2) configuring environment variables, (3) setting up preview deployments, (4) debugging build failures, (5) configuring domains, (6) seeing "No Next.js version detected" error in Vercel builds, (7) setting up monorepo with separate projects on free tier. Key insight: Vercel monorepos require Root Directory configuration via dashboard (not vercel.json), GitHub integration auto-detects monorepo structure, free tier allows multiple projects.
development
Master Supabase Row Level Security (RLS) for RidenDine. Use when: (1) adding new tables, (2) modifying RLS policies, (3) debugging access control issues, (4) role-based data access. Key insight: All tables use RLS with role-based policies from profiles.role column.