RoxyAPI

Menu

How to Build a Tarot Reading App in React Native (Complete Tutorial)

12 min read
By Ritika Agnihotri
TarotReact NativeMobile DevelopmentAPI Integration

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! 🔮✨