Skip to content

Mobile Guidelines

  • React Native with Expo
  • React Navigation (native stack)
  • NativeWind (Tailwind CSS for React Native)
  • Zustand (state management)
  • TanStack Query (data fetching)
  • Better Auth (authentication)
  • Lucide React Native (icons)
  • Jest + React Native Testing Library (testing)
  • EAS Build (builds and deployment)
Terminal window
# Start development server
pnpm --filter=@lonestone/mobile dev
# Start on specific platform
pnpm --filter=@lonestone/mobile ios
pnpm --filter=@lonestone/mobile android
# Run in web browser (for quick testing)
pnpm --filter=@lonestone/mobile web
Terminal window
# Development build (requires Expo Go or dev client)
pnpm --filter=@lonestone/mobile build:dev
# Preview build (internal distribution)
pnpm --filter=@lonestone/mobile build:preview
# Production build
pnpm --filter=@lonestone/mobile build:production
Terminal window
# Run tests
pnpm --filter=@lonestone/mobile test
# Run tests in watch mode
pnpm --filter=@lonestone/mobile test:watch
apps/mobile/
├── src/
│ ├── navigation/ # Navigation configuration
│ │ ├── types.ts # Navigation types
│ │ └── root-navigator.tsx
│ └── screens/ # Global screens
├── features/ # Feature modules
│ ├── common/
│ │ ├── hooks/
│ │ └── utils/
│ └── auth/
│ ├── components/ # Auth screens and components
│ ├── hooks/
│ └── utils/
├── components/ # Shared components
├── lib/ # External library configs
│ ├── auth-client.ts
│ └── query-client.tsx
├── store/ # Zustand stores
│ ├── auth-store.ts
│ ├── theme-store.ts
│ └── index.ts
├── config/ # App configuration
│ └── env.config.ts # Environment variables
├── assets/ # Images, fonts, etc.
├── App.tsx # App entry point
└── global.css # Global styles

Routes are defined in app/navigation/types.ts:

export type RootStackParamList = {
Login: undefined
Register: undefined
Home: undefined
Profile: undefined
}
import { useNavigation } from '@react-navigation/native'
import type { RootNavigationProp } from '@/src/navigation/types'
function MyScreen() {
const navigation = useNavigation<RootNavigationProp>()
const handleNavigate = () => {
navigation.navigate('Profile')
}
return <Button onPress={handleNavigate}>Go to Profile</Button>
}

The app uses conditional navigation based on authentication state. See src/navigation/root-navigator.tsx for the implementation.

import { useAuthStore } from '@/store'
function MyComponent() {
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
return <Text>{user?.name}</Text>
}
import { create } from 'zustand'
interface MyState {
count: number
increment: () => void
}
export const useMyStore = create<MyState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))

Structure query options in features/*/utils/*-queries.ts:

import { apiClient } from '@lonestone/openapi-generator'
export function fetchUserQueryOptions(userId: string) {
return {
queryKey: ['users', userId],
queryFn: () => apiClient.usersControllerFindOne({ path: { id: userId } }),
}
}
// In component
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery(fetchUserQueryOptions(userId))
if (isLoading) return <ActivityIndicator />
return <Text>{user?.name}</Text>
}

The mobile app uses the same Better Auth setup as the web apps:

import { authClient } from '@/lib/auth-client'
import { useAuthStore } from '@/store'
// Sign in
const { data, error } = await authClient.signIn.email({
email,
password,
})
if (data?.user) {
setUser(data.user)
}
// Sign out
await authClient.signOut()
logout()

Deep links are configured using a custom scheme (e.g., lonestone://):

app.config.ts
export default {
scheme: process.env.EXPO_PUBLIC_SCHEME || 'lonestone',
// ...
}

Use the useLinking hook for deep link navigation:

import { useLinking } from '@/navigation/use-linking'
import { useNavigation } from '@react-navigation/native'
function MyComponent() {
const navigation = useNavigation()
// Handle deep link on mount
useEffect(() => {
const handleDeepLink = async (url: string) => {
// Parse URL and extract params
const { hostname, queryParams } = Linking.parse(url)
if (hostname === 'reset-password' && queryParams?.token) {
navigation.navigate('ResetPassword', { token: queryParams.token })
}
}
// Get initial URL
Linking.getInitialURL().then((url) => {
if (url) handleDeepLink(url)
})
// Listen for incoming links
const subscription = Linking.addEventListener('url', (event) => {
handleDeepLink(event.url)
})
return () => subscription.remove()
}, [])
}

Test with:

Terminal window
# iOS Simulator
xcrun simctl openurl booted "lonestone://reset-password?token=YOUR_TOKEN"
# Or use uri-scheme utility
npx uri-scheme open "lonestone://reset-password?token=YOUR_TOKEN" --ios

Replace lonestone:// with your project’s custom scheme.

For production apps, use Universal Links (iOS) and App Links (Android) instead of custom schemes:

  1. Configure apple-app-site-association and assetlinks.json on your domain
  2. Use the utility script: pnpm create:universallinks
  3. Update iOS entitlements and Android manifest
  4. Configure API to send universal URLs instead of custom scheme URLs
import { View, Text } from 'react-native'
function MyComponent() {
return (
<View className="flex-1 justify-center items-center bg-white dark:bg-gray-900">
<Text className="text-2xl font-bold text-gray-900 dark:text-white">
Hello World
</Text>
</View>
)
}

Use the useTheme hook for dark mode:

import { useTheme } from '@/features/common/hooks/use-theme'
function MyComponent() {
const { colorScheme, toggleColorScheme, isDark } = useTheme()
return (
<Button onPress={toggleColorScheme}>
{isDark ? 'Light Mode' : 'Dark Mode'}
</Button>
)
}

Environment variables are validated with Zod in config/env.config.ts:

import { z } from 'zod'
import { EXPO_PUBLIC_API_URL } from 'react-native-dotenv'
export const configValidationSchema = z.object({
EXPO_PUBLIC_API_URL: z.string().url(),
})
export const config = {
apiUrl: EXPO_PUBLIC_API_URL,
} as const

Public environment variables must be prefixed with EXPO_PUBLIC_:

.env
EXPO_PUBLIC_API_URL=http://localhost:3000

Import from the config object, not directly from react-native-dotenv:

import { config } from '@/config/env.config'
const apiUrl = config.apiUrl
import React from 'react'
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
interface ButtonProps {
children: React.ReactNode
onPress: () => void
isLoading?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onPress,
isLoading = false,
variant = 'primary',
}: ButtonProps) {
return (
<TouchableOpacity
onPress={onPress}
disabled={isLoading}
className={`px-6 py-3 rounded-lg ${
variant === 'primary' ? 'bg-blue-600' : 'bg-gray-600'
}`}
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-white font-semibold">{children}</Text>
)}
</TouchableOpacity>
)
}
import { render, fireEvent } from '@testing-library/react-native'
import { Button } from '@/components/button'
describe('Button', () => {
it('should render correctly', () => {
const { getByText } = render(<Button onPress={() => {}}>Click me</Button>)
expect(getByText('Click me')).toBeTruthy()
})
it('should call onPress when clicked', () => {
const mockOnPress = jest.fn()
const { getByText } = render(
<Button onPress={mockOnPress}>Click me</Button>,
)
fireEvent.press(getByText('Click me'))
expect(mockOnPress).toHaveBeenCalledTimes(1)
})
})

The app has three build profiles defined in eas.json:

  • development: For development with Expo Go or dev client
  • preview: Internal distribution (APK for Android)
  • production: Store distribution (AAB for Android, IPA for iOS)
Terminal window
# Development build
eas build --profile development --platform ios
# Preview build
eas build --profile preview --platform all
# Production build
eas build --profile production --platform all

When you need features that Expo Go doesn’t support:

  1. Create a development build:

    Terminal window
    eas build --profile development --platform ios
  2. Install the dev client on your device

  3. Start the dev server:

    Terminal window
    pnpm --filter=@lonestone/mobile dev
  4. Open the app using the dev client instead of Expo Go

Use Lucide React Native icons only:

import { User, Settings } from 'lucide-react-native'
function MyComponent() {
return (
<View>
<User size={24} color="black" />
<Settings size={24} color="gray" />
</View>
)
}
  • Follow TypeScript best practices: No any types, use proper interfaces
  • Use functional components: Avoid class components
  • Keep components small: Single responsibility principle
  • Use custom hooks: Extract complex logic into hooks
  • Test important functionality: Write tests for critical features
  • Follow the project structure: Maintain consistency with web apps where possible
  • Use error boundaries: Handle errors gracefully
  • Optimize images: Use appropriate image formats and sizes
  • Handle loading states: Show loaders for async operations
  • Handle offline scenarios: Consider network connectivity