How to Build a Tarot Reading App in React Native (Complete Tutorial)
Learn how to build a complete tarot reading app with React Native and RoxyAPI. Includes card display UI, spread layouts, API integration, and state management for iOS and Android.
How to Build a Tarot Reading App in React Native (Complete Tutorial)
Building a tarot reading app has never been easier. With React Native and RoxyAPI's Tarot API, you can create a beautiful, cross-platform mobile app that delivers authentic tarot readings to users on both iOS and Android. In this comprehensive tutorial, I'll walk you through building a complete tarot reading app from scratch.
What You'll Build
By the end of this tutorial, you'll have a fully functional tarot app featuring:
- Card browsing with all 78 tarot cards
- Daily card reading with consistent daily draws
- Three-card spread (past, present, future)
- Card detail views with upright and reversed meanings
- Beautiful UI with tarot card imagery
Prerequisites
Before we begin, make sure you have:
- Node.js 18+ installed
- React Native development environment set up
- A RoxyAPI account with API key (Get one here)
- Basic knowledge of React Native and TypeScript
Project Setup
Let's start by creating a new React Native project using Expo:
# Create new Expo project
npx create-expo-app tarot-reading-app --template blank-typescript
cd tarot-reading-app
# Install dependencies
npm install axios @react-navigation/native @react-navigation/stack
npm install react-native-gesture-handler react-native-safe-area-context
API Integration Setup
First, let's set up our RoxyAPI integration. Create a new file src/services/tarotApi.ts:
import axios from 'axios';
const ROXYAPI_BASE_URL = 'https://roxyapi.com/api/v2/tarot';
const API_KEY = 'your_api_key_here'; // Get from https://roxyapi.com/pricing
const tarotApi = axios.create({
baseURL: ROXYAPI_BASE_URL,
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
});
export interface TarotCard {
id: string;
name: string;
arcana: 'major' | 'minor';
number: number;
suit?: string;
imageUrl: string;
keywords?: {
upright: string[];
reversed: string[];
};
}
export interface TarotCardDetail extends TarotCard {
upright: {
keywords: string[];
description: string;
};
reversed: {
keywords: string[];
description: string;
};
}
export interface DrawnCard extends TarotCard {
position: number;
reversed: boolean;
keywords: string[];
meaning: string;
}
// Get all cards (for browsing)
export const getAllCards = async (
arcana?: 'major' | 'minor'
): Promise<{ cards: TarotCard[]; total: number }> => {
const params = arcana ? { arcana } : {};
const response = await tarotApi.get('/cards', { params });
return response.data;
};
// Get single card details
export const getCardById = async (id: string): Promise<TarotCardDetail> => {
const response = await tarotApi.get(`/cards/${id}`);
return response.data;
};
// Get daily card (same card for same user/date)
export const getDailyCard = async (
userId: string,
date?: string
): Promise<DrawnCard> => {
const response = await tarotApi.post('/daily', {
userId,
date: date || new Date().toISOString().split('T')[0],
});
return response.data.card;
};
// Draw random cards (for spreads)
export const drawCards = async (
count: number,
allowReversals = true,
seed?: string
): Promise<DrawnCard[]> => {
const response = await tarotApi.post('/draw', {
count,
allowReversals,
seed,
});
return response.data.cards;
};
// Get three-card spread
export const getThreeCardSpread = async (
question?: string,
seed?: string
): Promise<{
spread: string;
question?: string;
cards: DrawnCard[];
}> => {
const response = await tarotApi.post('/spreads/three-card', {
question,
seed,
});
return response.data;
};
export default tarotApi;
Building the Home Screen
Create src/screens/HomeScreen.tsx:
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
SafeAreaView,
StatusBar,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
type Props = {
navigation: NativeStackNavigationProp<any>;
};
export default function HomeScreen({ navigation }: Props) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.content}>
<Text style={styles.title}>Tarot Reader</Text>
<Text style={styles.subtitle}>
Discover insights with ancient wisdom
</Text>
<View style={styles.buttonsContainer}>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate('DailyCard')}
>
<Text style={styles.buttonText}>📅 Daily Card</Text>
<Text style={styles.buttonDesc}>
Your card for today
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate('ThreeCardSpread')}
>
<Text style={styles.buttonText}>🔮 Three Card Reading</Text>
<Text style={styles.buttonDesc}>
Past, Present, Future
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={() => navigation.navigate('CardBrowser')}
>
<Text style={styles.buttonText}>📚 Browse Cards</Text>
<Text style={styles.buttonDesc}>
Explore all 78 tarot cards
</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#1a1a2e',
},
content: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
title: {
fontSize: 48,
fontWeight: 'bold',
color: '#fff',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#9d9d9d',
textAlign: 'center',
marginBottom: 48,
},
buttonsContainer: {
gap: 16,
},
button: {
backgroundColor: '#2a2a4e',
padding: 24,
borderRadius: 12,
borderWidth: 1,
borderColor: '#3a3a6e',
},
buttonText: {
fontSize: 20,
fontWeight: '600',
color: '#fff',
marginBottom: 4,
},
buttonDesc: {
fontSize: 14,
color: '#9d9d9d',
},
});
Daily Card Screen with API Integration
Create src/screens/DailyCardScreen.tsx:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
Image,
StyleSheet,
ActivityIndicator,
ScrollView,
SafeAreaView,
} from 'react-native';
import { getDailyCard, DrawnCard } from '../services/tarotApi';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function DailyCardScreen() {
const [card, setCard] = useState<DrawnCard | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDailyCard();
}, []);
const loadDailyCard = async () => {
try {
setLoading(true);
setError(null);
// Get or create user ID
let userId = await AsyncStorage.getItem('userId');
if (!userId) {
userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
await AsyncStorage.setItem('userId', userId);
}
const today = new Date().toISOString().split('T')[0];
const dailyCard = await getDailyCard(userId, today);
setCard(dailyCard);
} catch (err) {
console.error('Error loading daily card:', err);
setError('Failed to load daily card. Please try again.');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.loadingText}>Drawing your daily card...</Text>
</View>
);
}
if (error || !card) {
return (
<View style={styles.centerContainer}>
<Text style={styles.errorText}>{error || 'Card not found'}</Text>
</View>
);
}
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Text style={styles.date}>
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
<Text style={styles.title}>Your Daily Card</Text>
<View style={styles.cardContainer}>
<Image
source={{ uri: card.imageUrl }}
style={[
styles.cardImage,
card.reversed && styles.cardImageReversed,
]}
resizeMode="contain"
/>
</View>
<Text style={styles.cardName}>
{card.name}
{card.reversed && ' (Reversed)'}
</Text>
<View style={styles.keywordsContainer}>
{card.keywords.map((keyword, index) => (
<View key={index} style={styles.keywordBadge}>
<Text style={styles.keywordText}>{keyword}</Text>
</View>
))}
</View>
<View style={styles.meaningContainer}>
<Text style={styles.meaningTitle}>
{card.reversed ? 'Reversed Meaning' : 'Upright Meaning'}
</Text>
<Text style={styles.meaningText}>{card.meaning}</Text>
</View>
<Text style={styles.footer}>
Powered by{' '}
<Text style={styles.footerLink}>RoxyAPI Tarot API</Text>
</Text>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#1a1a2e',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#1a1a2e',
padding: 20,
},
scrollContent: {
padding: 20,
},
date: {
fontSize: 14,
color: '#9d9d9d',
textAlign: 'center',
marginBottom: 8,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#fff',
textAlign: 'center',
marginBottom: 32,
},
cardContainer: {
alignItems: 'center',
marginBottom: 24,
},
cardImage: {
width: 250,
height: 400,
borderRadius: 12,
},
cardImageReversed: {
transform: [{ rotate: '180deg' }],
},
cardName: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
textAlign: 'center',
marginBottom: 16,
},
keywordsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
marginBottom: 24,
},
keywordBadge: {
backgroundColor: '#2a2a4e',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: '#3a3a6e',
},
keywordText: {
color: '#fff',
fontSize: 14,
},
meaningContainer: {
backgroundColor: '#2a2a4e',
padding: 20,
borderRadius: 12,
borderWidth: 1,
borderColor: '#3a3a6e',
marginBottom: 24,
},
meaningTitle: {
fontSize: 18,
fontWeight: '600',
color: '#fff',
marginBottom: 12,
},
meaningText: {
fontSize: 16,
color: '#d0d0d0',
lineHeight: 24,
},
loadingText: {
color: '#fff',
marginTop: 16,
fontSize: 16,
},
errorText: {
color: '#ff6b6b',
fontSize: 16,
textAlign: 'center',
},
footer: {
textAlign: 'center',
color: '#9d9d9d',
fontSize: 14,
},
footerLink: {
color: '#fff',
fontWeight: '600',
},
});
Three-Card Spread Screen
Create src/screens/ThreeCardSpreadScreen.tsx:
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
Image,
StyleSheet,
ActivityIndicator,
ScrollView,
SafeAreaView,
} from 'react-native';
import { getThreeCardSpread, DrawnCard } from '../services/tarotApi';
export default function ThreeCardSpreadScreen() {
const [question, setQuestion] = useState('');
const [cards, setCards] = useState<DrawnCard[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDraw = async () => {
try {
setLoading(true);
setError(null);
const result = await getThreeCardSpread(
question || undefined,
undefined // Let API generate random seed
);
setCards(result.cards);
} catch (err) {
console.error('Error drawing cards:', err);
setError('Failed to draw cards. Please try again.');
} finally {
setLoading(false);
}
};
const positions = ['Past', 'Present', 'Future'];
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Text style={styles.title}>Three Card Spread</Text>
<Text style={styles.subtitle}>Past · Present · Future</Text>
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
Your Question (Optional)
</Text>
<TextInput
style={styles.input}
placeholder="What do I need to know about..."
placeholderTextColor="#666"
value={question}
onChangeText={setQuestion}
multiline
/>
</View>
<TouchableOpacity
style={styles.drawButton}
onPress={handleDraw}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.drawButtonText}>
🔮 Draw Cards
</Text>
)}
</TouchableOpacity>
{error && (
<Text style={styles.errorText}>{error}</Text>
)}
{cards.length > 0 && (
<View style={styles.cardsContainer}>
{cards.map((card, index) => (
<View key={card.id} style={styles.cardItem}>
<Text style={styles.positionLabel}>
{positions[index]}
</Text>
<Image
source={{ uri: card.imageUrl }}
style={[
styles.cardImage,
card.reversed && styles.cardImageReversed,
]}
resizeMode="contain"
/>
<Text style={styles.cardName}>
{card.name}
{card.reversed && ' (R)'}
</Text>
<View style={styles.keywordsContainer}>
{card.keywords.slice(0, 3).map((keyword, i) => (
<Text key={i} style={styles.keyword}>
{keyword}
</Text>
))}
</View>
</View>
))}
</View>
)}
{cards.length > 0 && (
<Text style={styles.footer}>
Learn more at{' '}
<Text style={styles.footerLink}>
RoxyAPI Docs
</Text>
</Text>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#1a1a2e',
},
scrollContent: {
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#fff',
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#9d9d9d',
textAlign: 'center',
marginBottom: 32,
},
inputContainer: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
color: '#9d9d9d',
marginBottom: 8,
},
input: {
backgroundColor: '#2a2a4e',
color: '#fff',
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: '#3a3a6e',
fontSize: 16,
minHeight: 80,
textAlignVertical: 'top',
},
drawButton: {
backgroundColor: '#4a4a8e',
padding: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 24,
},
drawButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: '600',
},
cardsContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 24,
},
cardItem: {
flex: 1,
alignItems: 'center',
},
positionLabel: {
fontSize: 12,
color: '#9d9d9d',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 1,
},
cardImage: {
width: '100%',
height: 180,
borderRadius: 8,
marginBottom: 8,
},
cardImageReversed: {
transform: [{ rotate: '180deg' }],
},
cardName: {
fontSize: 14,
fontWeight: '600',
color: '#fff',
textAlign: 'center',
marginBottom: 4,
},
keywordsContainer: {
alignItems: 'center',
},
keyword: {
fontSize: 11,
color: '#9d9d9d',
textAlign: 'center',
},
errorText: {
color: '#ff6b6b',
textAlign: 'center',
marginBottom: 16,
},
footer: {
textAlign: 'center',
color: '#9d9d9d',
fontSize: 14,
},
footerLink: {
color: '#fff',
fontWeight: '600',
},
});
Navigation Setup
Update App.tsx:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './src/screens/HomeScreen';
import DailyCardScreen from './src/screens/DailyCardScreen';
import ThreeCardSpreadScreen from './src/screens/ThreeCardSpreadScreen';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: '#1a1a2e',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="DailyCard"
component={DailyCardScreen}
options={{ title: 'Daily Card' }}
/>
<Stack.Screen
name="ThreeCardSpread"
component={ThreeCardSpreadScreen}
options={{ title: 'Three Card Spread' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
Running Your App
# Start the Expo dev server
npx expo start
# Press 'i' for iOS simulator
# Press 'a' for Android emulator
# Or scan QR code with Expo Go app on your phone
Key Features Explained
1. Daily Card Consistency
The getDailyCard function uses a combination of userId and date to ensure users get the same card throughout the day:
const dailyCard = await getDailyCard(userId, today);
RoxyAPI handles the seeding internally, providing consistent results without exposing the complexity.
2. Reversed Cards
The API automatically handles card reversals when allowReversals: true:
const response = await tarotApi.post('/draw', {
count: 3,
allowReversals: true,
});
Cards marked as reversed: true include reversed meanings and keywords.
3. Reproducible Readings
For features like saved readings or shareable spreads, use the seed parameter:
const spread = await getThreeCardSpread(question, 'unique-seed-123');
// Same seed = same cards every time
Production Considerations
1. Error Handling
Add proper error boundaries and retry logic:
const loadWithRetry = async (fn: () => Promise<any>, retries = 3) => {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
};
2. Image Caching
Use react-native-fast-image for better image performance:
npm install react-native-fast-image
3. Offline Support
Cache card data with AsyncStorage:
await AsyncStorage.setItem(
`card_${cardId}`,
JSON.stringify(cardData)
);
4. Analytics
Track popular spreads and cards for product insights.
Next Steps
Enhance your tarot app with:
- Custom spreads using the Custom Spread Builder endpoint
- Celtic Cross 10-card spread
- Yes/No readings for quick decisions
- Reading history with saved spreads
- Push notifications for daily card reminders
- Social sharing of reading results
Troubleshooting
API Key Errors
Error: Request failed with status code 401
Solution: Verify your API key at RoxyAPI Docs and check the header format:
headers: { 'X-API-Key': 'your_key_here' }
Rate Limiting
Error: Request failed with status code 429
Solution: Implement exponential backoff or upgrade your plan at RoxyAPI Pricing.
Image Loading Issues
If images don't load, check your network permissions in Info.plist (iOS) or AndroidManifest.xml (Android).
Conclusion
You've built a complete tarot reading app with React Native and RoxyAPI! The app features daily cards, three-card spreads, and beautiful card displays—all powered by professional tarot API endpoints.
RoxyAPI's Tarot API handles the complexity of card shuffling, reversal logic, and consistent daily draws, letting you focus on creating delightful user experiences.
Ready to add more features? Check out the Tarot API documentation for Celtic Cross spreads, custom spread builders, and more advanced endpoints.
Resources:
Happy coding! 🔮✨