Build a TCG Collection Tracker with React and TCG API
Tutorial: build a card collection tracker app with React that shows real-time values for your Pokemon, Magic, and Yu-Gi-Oh! cards using TCG API.
Every card collector wants to know one thing: “What’s my collection worth?” In this tutorial, you’ll build a React app that answers that question. Search for cards across any game, add them to your collection, and see your total portfolio value update in real time.
The app works with Pokemon, Magic: The Gathering, Yu-Gi-Oh!, and all 89+ games supported by TCG API.
What You’ll Build
A single-page React app with:
- Card search — Find cards by name across any supported game
- Collection management — Add cards, set quantities, pick printings (Normal/Foil)
- Portfolio value — See the total market value of your collection
- Price changes — Track how your collection value moves over time
All data is stored in localStorage, so there’s no backend needed.
Prerequisites
- Node.js 18+
- Basic React knowledge
- A free TCG API key (sign up here)
Step 1: Set Up the Project
Create a new React app and clean out the boilerplate:
npx create-react-app tcg-trackercd tcg-trackerCreate a .env file in the project root with your API key:
REACT_APP_TCG_API_KEY=your-api-key-hereStep 2: Build the API Helper
Create src/api.js to handle all TCG API communication:
const BASE_URL = 'https://api.tcgapi.dev/v1';const API_KEY = process.env.REACT_APP_TCG_API_KEY;
export async function searchCards(query, game = 'pokemon', perPage = 10) { const url = `${BASE_URL}/search/cards?q=${encodeURIComponent(query)}&game=${game}&per_page=${perPage}`; const response = await fetch(url, { headers: { 'Authorization': `Bearer ${API_KEY}` } });
if (!response.ok) { throw new Error(`API error: ${response.status}`); }
const data = await response.json(); return data.data || [];}
export async function getCard(cardId) { const response = await fetch(`${BASE_URL}/cards/${cardId}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } });
if (!response.ok) { throw new Error(`API error: ${response.status}`); }
const data = await response.json(); return data.data;}
export async function refreshPrices(cardIds) { // Fetch updated prices for a batch of cards const results = []; for (const id of cardIds) { try { const card = await getCard(id); results.push(card); } catch (err) { console.error(`Failed to refresh ${id}:`, err); } } return results;}Step 3: Build the Search Component
Create src/CardSearch.js for finding cards:
import { useState } from 'react';import { searchCards } from './api';
const GAMES = [ { value: 'pokemon', label: 'Pokemon' }, { value: 'magic', label: 'Magic: The Gathering' }, { value: 'yugioh', label: 'Yu-Gi-Oh!' }, { value: 'lorcana', label: 'Lorcana' }, { value: 'flesh-and-blood', label: 'Flesh & Blood' }, { value: 'one-piece-card-game', label: 'One Piece' },];
export default function CardSearch({ onAddCard }) { const [query, setQuery] = useState(''); const [game, setGame] = useState('pokemon'); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false);
async function handleSearch(e) { e.preventDefault(); if (!query.trim()) return;
setLoading(true); try { const cards = await searchCards(query, game); setResults(cards); } catch (err) { console.error('Search failed:', err); setResults([]); } setLoading(false); }
return ( <div className="card-search"> <form onSubmit={handleSearch}> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search for a card..." /> <select value={game} onChange={(e) => setGame(e.target.value)}> {GAMES.map(g => ( <option key={g.value} value={g.value}>{g.label}</option> ))} </select> <button type="submit" disabled={loading}> {loading ? 'Searching...' : 'Search'} </button> </form>
<div className="search-results"> {results.map(card => ( <SearchResult key={card.id} card={card} onAdd={onAddCard} /> ))} </div> </div> );}
function SearchResult({ card, onAdd }) { const printings = Object.entries(card.prices || {});
return ( <div className="search-result"> <div className="card-info"> <h3>{card.name}</h3> <p>{card.set_name} · {card.game_name}</p> </div> <div className="card-prices"> {printings.map(([printing, prices]) => ( <button key={printing} onClick={() => onAdd(card, printing)} className="add-btn" > Add {printing} — ${prices.market_price?.toFixed(2) || 'N/A'} </button> ))} </div> </div> );}Step 4: Collection State with localStorage
Create src/useCollection.js — a custom hook that manages the collection and persists it:
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'tcg-collection';
export default function useCollection() { const [collection, setCollection] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : []; } catch { return []; } });
// Persist to localStorage on every change useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(collection)); }, [collection]);
function addCard(card, printing) { setCollection(prev => { const key = `${card.id}-${printing}`; const existing = prev.find(c => c.key === key);
if (existing) { // Increment quantity if already in collection return prev.map(c => c.key === key ? { ...c, quantity: c.quantity + 1 } : c ); }
return [...prev, { key, id: card.id, name: card.name, set_name: card.set_name, game_name: card.game_name, printing, market_price: card.prices?.[printing]?.market_price || 0, quantity: 1, addedAt: new Date().toISOString(), lastPriceUpdate: new Date().toISOString() }]; }); }
function removeCard(key) { setCollection(prev => prev.filter(c => c.key !== key)); }
function updateQuantity(key, quantity) { if (quantity <= 0) { removeCard(key); return; } setCollection(prev => prev.map(c => c.key === key ? { ...c, quantity } : c) ); }
function updatePrices(updatedCards) { setCollection(prev => prev.map(item => { const updated = updatedCards.find(c => c.id === item.id); if (updated && updated.prices?.[item.printing]) { return { ...item, previousPrice: item.market_price, market_price: updated.prices[item.printing].market_price || item.market_price, lastPriceUpdate: new Date().toISOString() }; } return item; }) ); }
return { collection, addCard, removeCard, updateQuantity, updatePrices };}Step 5: Display the Collection
Create src/Collection.js to show the collection and total value:
export default function Collection({ collection, onUpdateQuantity, onRemove }) { const totalValue = collection.reduce( (sum, card) => sum + (card.market_price * card.quantity), 0 );
const totalCards = collection.reduce( (sum, card) => sum + card.quantity, 0 );
return ( <div className="collection"> <div className="portfolio-summary"> <h2>My Collection</h2> <div className="stats"> <div className="stat"> <span className="stat-value">{totalCards}</span> <span className="stat-label">Cards</span> </div> <div className="stat"> <span className="stat-value">${totalValue.toFixed(2)}</span> <span className="stat-label">Total Value</span> </div> </div> </div>
{collection.length === 0 ? ( <p className="empty">No cards yet. Search and add some cards above.</p> ) : ( <table className="collection-table"> <thead> <tr> <th>Card</th> <th>Set</th> <th>Printing</th> <th>Price</th> <th>Qty</th> <th>Value</th> <th>Change</th> <th></th> </tr> </thead> <tbody> {collection.map(card => ( <CollectionRow key={card.key} card={card} onUpdateQuantity={onUpdateQuantity} onRemove={onRemove} /> ))} </tbody> </table> )} </div> );}
function CollectionRow({ card, onUpdateQuantity, onRemove }) { const value = card.market_price * card.quantity; const priceChange = card.previousPrice ? card.market_price - card.previousPrice : null;
return ( <tr> <td><strong>{card.name}</strong></td> <td>{card.set_name}</td> <td>{card.printing}</td> <td>${card.market_price.toFixed(2)}</td> <td> <input type="number" min="0" value={card.quantity} onChange={(e) => onUpdateQuantity(card.key, parseInt(e.target.value) || 0)} style={{ width: '60px' }} /> </td> <td><strong>${value.toFixed(2)}</strong></td> <td> {priceChange !== null && ( <span className={priceChange >= 0 ? 'price-up' : 'price-down'}> {priceChange >= 0 ? '+' : ''}${priceChange.toFixed(2)} </span> )} </td> <td> <button onClick={() => onRemove(card.key)} className="remove-btn"> Remove </button> </td> </tr> );}Step 6: Wire It All Together
Update src/App.js to combine the search, collection, and price refresh:
import { useState } from 'react';import CardSearch from './CardSearch';import Collection from './Collection';import useCollection from './useCollection';import { refreshPrices } from './api';
export default function App() { const { collection, addCard, removeCard, updateQuantity, updatePrices } = useCollection(); const [refreshing, setRefreshing] = useState(false);
async function handleRefreshPrices() { setRefreshing(true); try { // Get unique card IDs const uniqueIds = [...new Set(collection.map(c => c.id))]; const updated = await refreshPrices(uniqueIds); updatePrices(updated); } catch (err) { console.error('Price refresh failed:', err); } setRefreshing(false); }
return ( <div className="app"> <header> <h1>TCG Collection Tracker</h1> <p>Track your card collection value across Pokemon, Magic, Yu-Gi-Oh!, and more.</p> </header>
<CardSearch onAddCard={addCard} />
<div className="actions"> <button onClick={handleRefreshPrices} disabled={refreshing || collection.length === 0} > {refreshing ? 'Refreshing...' : 'Refresh Prices'} </button> </div>
<Collection collection={collection} onUpdateQuantity={updateQuantity} onRemove={removeCard} />
<footer> <p> Prices from <a href="https://tcgapi.dev">TCG API</a>. Covers 89+ trading card games. </p> </footer> </div> );}Step 7: Price Change Tracking
The previousPrice field in our collection state lets us track changes between price refreshes. Each time you click “Refresh Prices,” the app fetches current market prices from TCG API, compares them against the stored values, and shows the difference in the Change column.
For more advanced tracking, you can save historical snapshots:
function saveSnapshot(collection) { const snapshots = JSON.parse( localStorage.getItem('tcg-snapshots') || '[]' );
snapshots.push({ date: new Date().toISOString(), totalValue: collection.reduce( (sum, c) => sum + (c.market_price * c.quantity), 0 ), cardCount: collection.reduce((sum, c) => sum + c.quantity, 0) });
// Keep last 90 days const cutoff = Date.now() - (90 * 24 * 60 * 60 * 1000); const filtered = snapshots.filter(s => new Date(s.date).getTime() > cutoff);
localStorage.setItem('tcg-snapshots', JSON.stringify(filtered));}Call saveSnapshot(collection) after each price refresh to build a history of your portfolio value over time.
Running the App
Start the dev server:
npm startOpen http://localhost:3000 and try:
- Search for “charizard ex” with Pokemon selected
- Click “Add Normal” or “Add Holofoil” to add cards
- Set quantities for cards you own multiples of
- Switch to Magic and search for “lightning bolt”
- Click “Refresh Prices” to update all prices from the API
TCG API Endpoints Used
This app uses three TCG API endpoints:
- Search Cards —
GET /v1/search/cards?q=...&game=...— Full-text card search - Get Card —
GET /v1/cards/{id}— Individual card with prices - Bulk Prices — For Pro tier users, fetch prices for entire sets at once instead of card by card
The free tier (100 req/day) supports a collection of about 50 unique cards with daily price refreshes. If you’re tracking a larger collection, the Hobby plan at $9.99/month gives you 1,000 requests per day.
Next Steps
From here, you could extend the app with:
- Chart the portfolio value over time using the snapshots
- Add price alerts — notify when a card drops below a target price
- Export/import — save your collection as JSON for backup
- Sort and filter — sort by value, game, price change, or date added
- Deck value calculator — import a decklist and calculate its total cost
- Build a Discord bot that reports your portfolio value (see our Discord bot tutorial)
Get Started
Ready to build your own collection tracker? Sign up for a free TCG API key and follow the quickstart guide to make your first API call in minutes. Pricing data is available for all 89+ supported games right out of the box.
Building a collection tracker or portfolio tool? Share it with us on Discord — we’d love to see it.
Ready to get started?
Free tier includes 100 requests per day. No credit card required.