# 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](https://roxyapi.com/products/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](https://roxyapi.com/pricing))
- 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:**
```json
{
  "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:**

```bash
# 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:**
```json
{
  "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

```javascript
// 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

```jsx
// 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

```css
/* 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)

```jsx
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)

```swift
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:**
```javascript
// Use native browser caching
<img 
  src={card.imageUrl} 
  alt={card.name}
  loading="lazy"  // Browser-native lazy loading
/>
```

**React Native:**
```javascript
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:

```jsx
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:

```html
<!-- 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:

```jsx
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:

```javascript
// 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:

```jsx
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:

```jsx
<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](https://roxyapi.com/products/tarot-api) uses monthly request limits. Check [pricing plans](https://roxyapi.com/pricing) 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

- **API Documentation**: [roxyapi.com/docs](https://roxyapi.com/docs)
- **Product Page**: [roxyapi.com/products/tarot-api](https://roxyapi.com/products/tarot-api)
- **Pricing**: [roxyapi.com/pricing](https://roxyapi.com/pricing)
- **Support**: support@roxyapi.com

---

**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.