RoxyAPI

Menu

Tarot Card Database API: Complete Guide for Spiritual App Developers

15 min read
By Michael Anderson
TarotSpiritual AppsAPI IntegrationMobile Development

Build spiritual apps with a comprehensive tarot card database API. Learn filtering by arcana and suit, displaying upright and reversed meanings, image optimization, and building card browsers with complete code examples.

Tarot Card Database API: Complete Guide for Spiritual App Developers

Building a spiritual app with tarot features? You need access to a complete, professionally curated card database with detailed meanings, keywords, high-quality images, and robust filtering capabilities. Managing your own database means scraping unreliable sources, handling inconsistent interpretations, and maintaining 78+ card entries with upright and reversed meanings.

The RoxyAPI Tarot API provides a production-ready card database: all 78 Rider-Waite-Smith cards with professionally written interpretations, keyword arrays for quick reference, CDN-hosted images, and flexible filtering by arcana, suit, and card number. This guide shows you how to build card browsers, search features, and learning tools using the card database endpoints.

What You Will Build

By the end of this tutorial, you will have:

  1. Card Browser - Paginated grid displaying all 78 cards with filtering
  2. Card Detail View - Full interpretations, keywords, upright/reversed meanings
  3. Search & Filter - Filter by major/minor arcana, suit (cups/wands/swords/pentacles), card number
  4. Learning Mode - Flashcard-style interface for memorizing meanings
  5. Image Gallery - Optimized card image loading with caching

Prerequisites

Development Environment:

  • Web: React/Vue/Svelte OR Mobile: React Native/Flutter/SwiftUI
  • RoxyAPI Tarot API Key (get one here)
  • Image caching library (optional but recommended)

API Base URL:

https://roxyapi.com/api/v2/tarot

Authentication: All requests require X-API-Key header.

API Overview: Card Database Endpoints

Endpoint 1: List All Cards

Get the complete 78-card deck with optional filtering.

Endpoint:

GET /tarot/cards

Query Parameters:

  • arcana (optional): Filter by major (22 cards) or minor (56 cards)
  • suit (optional): Filter minor arcana by cups, wands, swords, pentacles (14 cards each)
  • number (optional): Filter by card number (1-14 for minor, where Ace=1, Page=11, Knight=12, Queen=13, King=14)

Response Format:

{
  "total": 22,
  "cards": [
    {
      "id": "fool",
      "name": "The Fool",
      "arcana": "major",
      "number": 0,
      "imageUrl": "https://roxyapi.com/img/tarot/major/fool.jpg"
    },
    {
      "id": "magician",
      "name": "The Magician",
      "arcana": "major",
      "number": 1,
      "imageUrl": "https://roxyapi.com/img/tarot/major/magician.jpg"
    }
  ]
}

Example Requests:

# All cards (78 total)
curl -H "X-API-Key: YOUR_KEY" \
  https://roxyapi.com/api/v2/tarot/cards

# Major arcana only (22 cards)
curl -H "X-API-Key: YOUR_KEY" \
  https://roxyapi.com/api/v2/tarot/cards?arcana=major

# All cups cards (14 cards)
curl -H "X-API-Key: YOUR_KEY" \
  https://roxyapi.com/api/v2/tarot/cards?suit=cups

# All Aces (4 cards: Ace of Cups, Wands, Swords, Pentacles)
curl -H "X-API-Key: YOUR_KEY" \
  https://roxyapi.com/api/v2/tarot/cards?number=1

# Specific card: Ace of Wands only
curl -H "X-API-Key: YOUR_KEY" \
  https://roxyapi.com/api/v2/tarot/cards?suit=wands&number=1

Endpoint 2: Get Single Card Details

Retrieve complete information for a specific card including full upright/reversed interpretations.

Endpoint:

GET /tarot/cards/{id}

Path Parameter:

  • id: Card identifier in kebab-case (e.g., fool, ace-of-cups, queen-of-swords, king-of-pentacles)

Response Format:

{
  "id": "lovers",
  "name": "The Lovers",
  "arcana": "major",
  "number": 6,
  "keywords": {
    "upright": ["Love", "harmony", "relationships", "values alignment", "choices"],
    "reversed": ["Self-love", "disharmony", "imbalance", "misalignment of values"]
  },
  "upright": {
    "keywords": ["Love", "harmony", "relationships", "values alignment", "choices"],
    "description": "In its purest form, The Lovers card represents conscious connections and meaningful relationships..."
  },
  "reversed": {
    "keywords": ["Self-love", "disharmony", "imbalance", "misalignment of values"],
    "description": "The Lovers card is pure love and harmony. Reversed, it can signal a time when you are out of sync..."
  },
  "imageUrl": "https://roxyapi.com/img/tarot/major/lovers.jpg"
}

Key Fields:

  • keywords.upright / keywords.reversed: Quick reference arrays for app UI
  • upright.description / reversed.description: Full interpretations (500-1000 words each)
  • imageUrl: CDN-hosted high-quality card image (optimized for web/mobile)

Implementation: Card Browser (React)

Step 1: API Service Layer

// services/tarotAPI.js
const TAROT_API_KEY = process.env.REACT_APP_TAROT_API_KEY;
const BASE_URL = 'https://roxyapi.com/api/v2/tarot';

export async function fetchCards(filters = {}) {
  const params = new URLSearchParams();
  
  if (filters.arcana) params.append('arcana', filters.arcana);
  if (filters.suit) params.append('suit', filters.suit);
  if (filters.number) params.append('number', filters.number);
  
  const url = `${BASE_URL}/cards${params.toString() ? `?${params}` : ''}`;
  
  const response = await fetch(url, {
    headers: { 'X-API-Key': TAROT_API_KEY }
  });
  
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  
  return response.json();
}

export async function fetchCardDetail(cardId) {
  const response = await fetch(`${BASE_URL}/cards/${cardId}`, {
    headers: { 'X-API-Key': TAROT_API_KEY }
  });
  
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  
  return response.json();
}

Step 2: Card Browser Component

// components/CardBrowser.jsx
import React, { useState, useEffect } from 'react';
import { fetchCards } from '../services/tarotAPI';
import './CardBrowser.css';

export default function CardBrowser() {
  const [cards, setCards] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filters, setFilters] = useState({
    arcana: '',
    suit: '',
    number: ''
  });
  
  useEffect(() => {
    loadCards();
  }, [filters]);
  
  async function loadCards() {
    setLoading(true);
    try {
      const cleanFilters = Object.fromEntries(
        Object.entries(filters).filter(([_, value]) => value !== '')
      );
      
      const data = await fetchCards(cleanFilters);
      setCards(data.cards);
    } catch (error) {
      console.error('Failed to load cards:', error);
    } finally {
      setLoading(false);
    }
  }
  
  function handleFilterChange(key, value) {
    setFilters(prev => ({
      ...prev,
      [key]: value,
      // Clear suit filter when switching to major arcana
      ...(key === 'arcana' && value === 'major' && { suit: '' })
    }));
  }
  
  return (
    <div className="card-browser">
      {/* Filter Bar */}
      <div className="filter-bar">
        <div className="filter-group">
          <label>Arcana</label>
          <select 
            value={filters.arcana} 
            onChange={(e) => handleFilterChange('arcana', e.target.value)}
          >
            <option value="">All Cards (78)</option>
            <option value="major">Major Arcana (22)</option>
            <option value="minor">Minor Arcana (56)</option>
          </select>
        </div>
        
        {filters.arcana !== 'major' && (
          <div className="filter-group">
            <label>Suit</label>
            <select 
              value={filters.suit} 
              onChange={(e) => handleFilterChange('suit', e.target.value)}
              disabled={filters.arcana === 'major'}
            >
              <option value="">All Suits</option>
              <option value="cups">Cups (Emotions)</option>
              <option value="wands">Wands (Passion)</option>
              <option value="swords">Swords (Intellect)</option>
              <option value="pentacles">Pentacles (Material)</option>
            </select>
          </div>
        )}
        
        {filters.arcana === 'minor' && (
          <div className="filter-group">
            <label>Card Number</label>
            <select 
              value={filters.number} 
              onChange={(e) => handleFilterChange('number', e.target.value)}
            >
              <option value="">All Numbers</option>
              <option value="1">Ace</option>
              {[2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (
                <option key={n} value={n}>{n}</option>
              ))}
              <option value="11">Page</option>
              <option value="12">Knight</option>
              <option value="13">Queen</option>
              <option value="14">King</option>
            </select>
          </div>
        )}
        
        <button onClick={() => setFilters({ arcana: '', suit: '', number: '' })}>
          Clear Filters
        </button>
      </div>
      
      {/* Card Count */}
      <div className="card-count">
        {loading ? 'Loading...' : `${cards.length} cards`}
      </div>
      
      {/* Card Grid */}
      <div className="card-grid">
        {loading ? (
          <div className="loading-spinner">Loading cards...</div>
        ) : (
          cards.map(card => (
            <CardTile key={card.id} card={card} />
          ))
        )}
      </div>
    </div>
  );
}

function CardTile({ card }) {
  return (
    <a href={`/cards/${card.id}`} className="card-tile">
      <div className="card-image-container">
        <img 
          src={card.imageUrl} 
          alt={card.name}
          loading="lazy"
        />
      </div>
      <div className="card-info">
        <h3>{card.name}</h3>
        <p className="card-meta">
          {card.arcana === 'major' ? 'Major Arcana' : card.suit}
          {' • '}
          {card.number !== undefined && `#${card.number}`}
        </p>
      </div>
    </a>
  );
}

Step 3: Styling

/* CardBrowser.css */
.card-browser {
  max-width: 1400px;
  margin: 0 auto;
  padding: 2rem;
}

.filter-bar {
  display: flex;
  gap: 1rem;
  padding: 1.5rem;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.filter-group {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.filter-group label {
  font-size: 0.875rem;
  font-weight: 600;
  color: #374151;
}

.filter-group select {
  padding: 0.5rem 1rem;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 0.875rem;
  background: white;
  cursor: pointer;
}

.filter-group select:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.filter-bar button {
  padding: 0.5rem 1.5rem;
  background: #6366f1;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  align-self: flex-end;
  font-weight: 500;
}

.card-count {
  margin-bottom: 1rem;
  color: #6b7280;
  font-size: 0.875rem;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 1.5rem;
}

.card-tile {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s, box-shadow 0.2s;
  text-decoration: none;
  color: inherit;
}

.card-tile:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}

.card-image-container {
  aspect-ratio: 2/3;
  overflow: hidden;
  background: #f3f4f6;
}

.card-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card-info {
  padding: 1rem;
}

.card-info h3 {
  margin: 0 0 0.5rem 0;
  font-size: 1rem;
  font-weight: 600;
}

.card-meta {
  margin: 0;
  font-size: 0.875rem;
  color: #6b7280;
  text-transform: capitalize;
}

.loading-spinner {
  grid-column: 1 / -1;
  text-align: center;
  padding: 4rem;
  color: #6b7280;
}

/* Responsive */
@media (max-width: 768px) {
  .card-browser {
    padding: 1rem;
  }
  
  .filter-bar {
    flex-direction: column;
  }
  
  .card-grid {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 1rem;
  }
}

Implementation: Card Detail View (React Native)

import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  Image,
  ScrollView,
  StyleSheet,
  ActivityIndicator,
  TouchableOpacity,
  Dimensions
} from 'react-native';
import { fetchCardDetail } from '../services/tarotAPI';

export default function CardDetailScreen({ route }) {
  const { cardId } = route.params;
  const [card, setCard] = useState(null);
  const [loading, setLoading] = useState(true);
  const [showReversed, setShowReversed] = useState(false);
  
  useEffect(() => {
    loadCard();
  }, [cardId]);
  
  async function loadCard() {
    try {
      const data = await fetchCardDetail(cardId);
      setCard(data);
    } catch (error) {
      console.error('Failed to load card:', error);
    } finally {
      setLoading(false);
    }
  }
  
  if (loading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" color="#6366f1" />
      </View>
    );
  }
  
  if (!card) {
    return (
      <View style={styles.centered}>
        <Text style={styles.errorText}>Card not found</Text>
      </View>
    );
  }
  
  const orientation = showReversed ? card.reversed : card.upright;
  
  return (
    <ScrollView style={styles.container}>
      {/* Card Image */}
      <View style={styles.imageContainer}>
        <Image
          source={{ uri: card.imageUrl }}
          style={[
            styles.cardImage,
            showReversed && styles.cardImageReversed
          ]}
          resizeMode="contain"
        />
      </View>
      
      {/* Card Header */}
      <View style={styles.header}>
        <Text style={styles.cardName}>{card.name}</Text>
        <Text style={styles.cardMeta}>
          {card.arcana === 'major' 
            ? `Major Arcana • Card ${card.number}`
            : `${card.suit.charAt(0).toUpperCase() + card.suit.slice(1)} • ${getCardNumberName(card.number)}`
          }
        </Text>
      </View>
      
      {/* Orientation Toggle */}
      <View style={styles.orientationToggle}>
        <TouchableOpacity
          style={[
            styles.toggleButton,
            !showReversed && styles.toggleButtonActive
          ]}
          onPress={() => setShowReversed(false)}
        >
          <Text style={[
            styles.toggleText,
            !showReversed && styles.toggleTextActive
          ]}>
            Upright
          </Text>
        </TouchableOpacity>
        
        <TouchableOpacity
          style={[
            styles.toggleButton,
            showReversed && styles.toggleButtonActive
          ]}
          onPress={() => setShowReversed(true)}
        >
          <Text style={[
            styles.toggleText,
            showReversed && styles.toggleTextActive
          ]}>
            Reversed
          </Text>
        </TouchableOpacity>
      </View>
      
      {/* Keywords */}
      <View style={styles.section}>
        <Text style={styles.sectionTitle}>Keywords</Text>
        <View style={styles.keywords}>
          {orientation.keywords.map((keyword, index) => (
            <View key={index} style={styles.keywordChip}>
              <Text style={styles.keywordText}>{keyword}</Text>
            </View>
          ))}
        </View>
      </View>
      
      {/* Description */}
      <View style={styles.section}>
        <Text style={styles.sectionTitle}>Meaning</Text>
        <Text style={styles.description}>
          {orientation.description}
        </Text>
      </View>
    </ScrollView>
  );
}

function getCardNumberName(number) {
  const names = {
    1: 'Ace',
    11: 'Page',
    12: 'Knight',
    13: 'Queen',
    14: 'King'
  };
  return names[number] || number.toString();
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f9fafb',
  },
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  imageContainer: {
    backgroundColor: 'white',
    padding: 24,
    alignItems: 'center',
  },
  cardImage: {
    width: Dimensions.get('window').width * 0.6,
    height: Dimensions.get('window').width * 0.9,
    borderRadius: 12,
  },
  cardImageReversed: {
    transform: [{ rotate: '180deg' }],
  },
  header: {
    padding: 24,
    backgroundColor: 'white',
    borderBottomWidth: 1,
    borderBottomColor: '#e5e7eb',
  },
  cardName: {
    fontSize: 28,
    fontWeight: 'bold',
    color: '#1f2937',
    marginBottom: 8,
  },
  cardMeta: {
    fontSize: 16,
    color: '#6b7280',
    textTransform: 'capitalize',
  },
  orientationToggle: {
    flexDirection: 'row',
    padding: 16,
    gap: 12,
  },
  toggleButton: {
    flex: 1,
    paddingVertical: 12,
    backgroundColor: '#f3f4f6',
    borderRadius: 8,
    alignItems: 'center',
  },
  toggleButtonActive: {
    backgroundColor: '#6366f1',
  },
  toggleText: {
    fontSize: 16,
    fontWeight: '600',
    color: '#6b7280',
  },
  toggleTextActive: {
    color: 'white',
  },
  section: {
    padding: 24,
    backgroundColor: 'white',
    marginTop: 16,
  },
  sectionTitle: {
    fontSize: 20,
    fontWeight: 'bold',
    color: '#1f2937',
    marginBottom: 16,
  },
  keywords: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 8,
  },
  keywordChip: {
    backgroundColor: '#eef2ff',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 20,
  },
  keywordText: {
    fontSize: 14,
    color: '#6366f1',
    fontWeight: '500',
  },
  description: {
    fontSize: 16,
    lineHeight: 24,
    color: '#374151',
  },
  errorText: {
    fontSize: 16,
    color: '#ef4444',
  },
});

Implementation: Flashcard Learning Mode (SwiftUI)

import SwiftUI

struct FlashcardView: View {
    @State private var cards: [TarotCard] = []
    @State private var currentIndex = 0
    @State private var showAnswer = false
    @State private var offset: CGSize = .zero
    
    var body: some View {
        VStack(spacing: 32) {
            // Progress
            HStack {
                Text("Card \(currentIndex + 1) of \(cards.count)")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                
                Spacer()
                
                Text("\(getCorrectCount()) correct")
                    .font(.subheadline)
                    .foregroundColor(.green)
            }
            .padding(.horizontal)
            
            // Flashcard
            if !cards.isEmpty {
                GeometryReader { geometry in
                    ZStack {
                        ForEach(cards.indices.reversed(), id: \.self) { index in
                            if index == currentIndex {
                                FlashcardContent(
                                    card: cards[index],
                                    showAnswer: showAnswer
                                )
                                .frame(width: geometry.size.width - 48, height: 500)
                                .offset(offset)
                                .rotationEffect(.degrees(Double(offset.width) / 20))
                                .gesture(
                                    DragGesture()
                                        .onChanged { gesture in
                                            offset = gesture.translation
                                        }
                                        .onEnded { gesture in
                                            handleSwipe(gesture)
                                        }
                                )
                            }
                        }
                    }
                }
                .frame(height: 500)
            }
            
            // Controls
            VStack(spacing: 16) {
                Button {
                    withAnimation {
                        showAnswer.toggle()
                    }
                } label: {
                    Text(showAnswer ? "Hide Meaning" : "Show Meaning")
                        .font(.headline)
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.purple.opacity(0.1))
                        .foregroundColor(.purple)
                        .cornerRadius(12)
                }
                
                if showAnswer {
                    HStack(spacing: 16) {
                        Button {
                            markIncorrect()
                        } label: {
                            Label("Retry", systemImage: "xmark")
                                .frame(maxWidth: .infinity)
                                .padding()
                                .background(Color.red.opacity(0.1))
                                .foregroundColor(.red)
                                .cornerRadius(12)
                        }
                        
                        Button {
                            markCorrect()
                        } label: {
                            Label("Got It", systemImage: "checkmark")
                                .frame(maxWidth: .infinity)
                                .padding()
                                .background(Color.green.opacity(0.1))
                                .foregroundColor(.green)
                                .cornerRadius(12)
                        }
                    }
                }
            }
            .padding()
        }
        .task {
            await loadCards()
        }
    }
    
    func handleSwipe(_ gesture: DragGesture.Value) {
        if abs(gesture.translation.width) > 100 {
            if gesture.translation.width > 0 {
                markCorrect()
            } else {
                markIncorrect()
            }
        } else {
            withAnimation {
                offset = .zero
            }
        }
    }
    
    func markCorrect() {
        withAnimation {
            currentIndex = (currentIndex + 1) % cards.count
            showAnswer = false
            offset = .zero
        }
    }
    
    func markIncorrect() {
        withAnimation {
            offset = .zero
            showAnswer = false
        }
    }
    
    func loadCards() async {
        // Load cards from API
        let service = TarotAPIService(apiKey: Config.apiKey)
        do {
            let response = try await service.fetchCards()
            cards = response.cards.shuffled()
        } catch {
            print("Failed to load cards:", error)
        }
    }
    
    func getCorrectCount() -> Int {
        // Track correct answers
        return 0
    }
}

struct FlashcardContent: View {
    let card: TarotCard
    let showAnswer: Bool
    
    var body: some View {
        VStack(spacing: 20) {
            AsyncImage(url: URL(string: card.imageUrl)) { image in
                image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
            } placeholder: {
                Color.gray.opacity(0.2)
            }
            .frame(height: 300)
            .cornerRadius(12)
            
            Text(card.name)
                .font(.title.bold())
            
            if showAnswer {
                ScrollView {
                    VStack(alignment: .leading, spacing: 12) {
                        Text("Keywords")
                            .font(.headline)
                        
                        // Show keywords here
                        
                        Text("Upright Meaning")
                            .font(.headline)
                            .padding(.top)
                        
                        // Show upright meaning
                    }
                    .padding()
                }
                .transition(.opacity)
            }
        }
        .padding()
        .background(Color.white)
        .cornerRadius(20)
        .shadow(radius: 10)
    }
}

Image Optimization Strategies

1. CDN Caching

All images are served from https://roxyapi.com/img/tarot/ with aggressive caching headers. Implement client-side caching:

React:

// Use native browser caching
<img 
  src={card.imageUrl} 
  alt={card.name}
  loading="lazy"  // Browser-native lazy loading
/>

React Native:

import FastImage from 'react-native-fast-image';

<FastImage
  source={{
    uri: card.imageUrl,
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable
  }}
  style={styles.cardImage}
  resizeMode={FastImage.resizeMode.contain}
/>

2. Progressive Loading

Show low-quality placeholder while high-res image loads:

import { useState } from 'react';

function ProgressiveImage({ src, placeholder, alt }) {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [imageLoaded, setImageLoaded] = useState(false);
  
  return (
    <img
      src={imageSrc}
      alt={alt}
      onLoad={() => {
        const img = new Image();
        img.onload = () => {
          setImageSrc(src);
          setImageLoaded(true);
        };
        img.src = src;
      }}
      style={{
        filter: imageLoaded ? 'none' : 'blur(10px)',
        transition: 'filter 0.3s'
      }}
    />
  );
}

3. Responsive Images

Serve appropriate sizes for different devices:

<!-- Not needed for RoxyAPI - images are already optimized -->
<!-- But you can create thumbnails on your backend if needed -->
<img
  src={card.imageUrl}
  srcSet={`
    ${card.imageUrl} 1x,
    ${card.imageUrl} 2x
  `}
  sizes="(max-width: 768px) 100vw, 33vw"
  alt={card.name}
/>

Search & Autocomplete

Build a card search with autocomplete:

import { useState, useEffect, useMemo } from 'react';
import { fetchCards } from '../services/tarotAPI';

export default function CardSearch() {
  const [allCards, setAllCards] = useState([]);
  const [searchTerm, setSearchTerm] = useState('');
  const [suggestions, setSuggestions] = useState([]);
  
  useEffect(() => {
    // Load all cards once for client-side search
    fetchCards().then(data => setAllCards(data.cards));
  }, []);
  
  const filteredCards = useMemo(() => {
    if (!searchTerm) return [];
    
    const term = searchTerm.toLowerCase();
    return allCards.filter(card =>
      card.name.toLowerCase().includes(term) ||
      card.arcana.toLowerCase().includes(term) ||
      card.suit?.toLowerCase().includes(term)
    ).slice(0, 10); // Limit to 10 suggestions
  }, [searchTerm, allCards]);
  
  return (
    <div className="search-container">
      <input
        type="text"
        placeholder="Search cards..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="search-input"
      />
      
      {searchTerm && (
        <div className="suggestions">
          {filteredCards.map(card => (
            <a
              key={card.id}
              href={`/cards/${card.id}`}
              className="suggestion-item"
            >
              <img src={card.imageUrl} alt={card.name} />
              <div>
                <div className="suggestion-name">{card.name}</div>
                <div className="suggestion-meta">
                  {card.arcana} {card.suit && `• ${card.suit}`}
                </div>
              </div>
            </a>
          ))}
          
          {filteredCards.length === 0 && (
            <div className="no-results">No cards found</div>
          )}
        </div>
      )}
    </div>
  );
}

Production Best Practices

1. Rate Limit Awareness

Cache card list and details locally to minimize API calls:

// Cache in localStorage (web) or AsyncStorage (React Native)
const CACHE_KEY = 'tarot_cards_cache';
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days

async function fetchCardsWithCache() {
  const cached = localStorage.getItem(CACHE_KEY);
  
  if (cached) {
    const { data, timestamp } = JSON.parse(cached);
    if (Date.now() - timestamp < CACHE_DURATION) {
      return data;
    }
  }
  
  const fresh = await fetchCards();
  localStorage.setItem(CACHE_KEY, JSON.stringify({
    data: fresh,
    timestamp: Date.now()
  }));
  
  return fresh;
}

2. Error Handling

Provide graceful fallbacks:

function ErrorBoundary({ error, retry }) {
  return (
    <div className="error-container">
      <h3>Failed to load cards</h3>
      <p>{error.message}</p>
      <button onClick={retry}>Try Again</button>
    </div>
  );
}

3. Accessibility

Ensure screen reader support:

<img
  src={card.imageUrl}
  alt={`${card.name} tarot card from the ${card.arcana} arcana${
    card.suit ? `, suit of ${card.suit}` : ''
  }`}
  role="img"
/>

<button
  aria-label={`View details for ${card.name}`}
  onClick={() => navigate(`/cards/${card.id}`)}
>
  {card.name}
</button>

Pricing Considerations

The RoxyAPI Tarot API uses monthly request limits. Check pricing plans for details.

Cost Optimization Tips:

  • Cache all 78 cards locally after first load (1 API call)
  • Card details rarely change - cache for 7+ days
  • Use client-side filtering/search on cached data
  • Only fetch fresh data when explicitly requested (pull-to-refresh)

Example Usage Estimate:

  • Initial load: 1 request (all 78 cards)
  • Card details: 78 requests (if all viewed)
  • Per user: ~10-20 API calls total (most users view 10-20 cards)
  • 1000 active users = ~15,000 requests/month

Troubleshooting

Q: Images not loading or 404 errors A: Verify imageUrl is used correctly. All images are at https://roxyapi.com/img/tarot/major/ or /minor/. Ensure no CORS issues in browser.

Q: Filter combinations return empty results A: Some combinations are invalid (e.g., arcana=major&suit=cups). Major arcana has no suits. Disable suit filter when major arcana is selected.

Q: Card IDs not matching A: Use exact kebab-case IDs from API: fool, ace-of-cups, queen-of-swords. Check for typos.

Q: Descriptions too long for mobile UI A: Truncate with "Read More": description.substring(0, 200) + '...' and expand on tap.

Next Steps

You now have a complete tarot card database integration! Build further:

  1. Favorites System - Let users bookmark cards for quick reference
  2. Study Decks - Create custom collections for learning
  3. Reverse Image Search - Upload card photo to identify it
  4. Card Comparisons - Side-by-side view of similar cards
  5. Daily Card Widget - Display random card on home screen

Resources


About the Author: Michael Anderson is a full-stack developer specializing in spiritual and wellness applications, with experience building card database interfaces, learning tools, and content management systems for spiritual practices.