What We're Building
In this tutorial, we'll build a Pokemon card price tracker that:
- Searches Pokemon cards by name
- Displays current market prices
- Shows pricing by card condition (Near Mint, Lightly Played, etc.)
- Fetches live marketplace listings
- Tracks price changes over time with sales history
We'll use Next.js for the frontend and TCGAPIs for all our data needs.
Prerequisites
Before we start, make sure you have:
- Node.js 18+ installed
- A TCGAPIs account with an API key (sign up here)
- Basic knowledge of React and Next.js
If you haven't already, follow our getting started guide to set up your API key.
Step 1: Project Setup
Create a new Next.js project:
npx create-next-app@latest pokemon-price-tracker --typescript --tailwind --app
cd pokemon-price-trackerCreate a .env.local file for your API key:
TCGAPIS_KEY=your-api-key-here
TCGAPIS_BASE_URL=https://api.tcgapis.com/api/v1Step 2: Create the API Helper
See how TCGAPIs compares to other options in our TCG API comparison. Now let's create a server-side API helper that handles authentication:
// src/lib/tcgapis.ts
const API_KEY = process.env.TCGAPIS_KEY;
const BASE_URL = process.env.TCGAPIS_BASE_URL;
async function apiRequest<T>(endpoint: string): Promise<T> {
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`,
},
next: { revalidate: 600 }, // Cache for 10 minutes
});
if (!response.ok) {
throw new Error(`TCGAPIs error: ${response.status}`);
}
return response.json();
}
export async function getPokemonExpansions() {
// Category ID 3 = Pokemon
return apiRequest('/expansions/3');
}
export async function getCards(groupId: string) {
return apiRequest(`/cards/${groupId}`);
}
export async function getPrices(productId: string) {
return apiRequest(`/prices/${productId}`);
}
export async function getSkuPrices(skuId: string) {
return apiRequest(`/skuprices/${skuId}`);
}
export async function getLiveListings(productId: string) {
return apiRequest(`/livelistings/${productId}`);
}Step 3: Build the Search Component
Create a search interface that lets users find Pokemon cards:
// src/components/CardSearch.tsx
'use client';
import { useState } from 'react';
interface Card {
productId: number;
name: string;
cleanName: string;
imageUrl: string;
groupId: number;
}
export default function CardSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Card[]>([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setResults(data.cards || []);
} finally {
setLoading(false);
}
};
return (
<div>
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search Pokemon cards..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg"
>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{results.map((card) => (
<a
key={card.productId}
href={`/card/${card.productId}`}
className="border rounded-lg p-3 hover:shadow-lg transition"
>
<img
src={card.imageUrl}
alt={card.name}
className="w-full rounded"
/>
<p className="mt-2 text-sm font-medium">{card.name}</p>
</a>
))}
</div>
</div>
);
}Step 4: Create the Price Display
Build a component that shows detailed pricing information:
// src/components/PriceDisplay.tsx
interface PriceData {
lowPrice: number;
midPrice: number;
highPrice: number;
marketPrice: number;
directLowPrice: number | null;
}
export default function PriceDisplay({
prices
}: {
prices: PriceData
}) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Market Price</p>
<p className="text-2xl font-bold text-green-600">
${prices.marketPrice?.toFixed(2) ?? 'N/A'}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Low</p>
<p className="text-xl font-semibold">
${prices.lowPrice?.toFixed(2) ?? 'N/A'}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">Mid</p>
<p className="text-xl font-semibold">
${prices.midPrice?.toFixed(2) ?? 'N/A'}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-500">High</p>
<p className="text-xl font-semibold">
${prices.highPrice?.toFixed(2) ?? 'N/A'}
</p>
</div>
</div>
);
}Step 5: Live Listings Component
Show what's actually available for sale right now:
// src/components/LiveListings.tsx
interface Listing {
sellerName: string;
condition: string;
price: number;
quantity: number;
shipping: number;
}
export default function LiveListings({
listings
}: {
listings: Listing[]
}) {
return (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left">Seller</th>
<th className="px-4 py-3 text-left">Condition</th>
<th className="px-4 py-3 text-right">Price</th>
<th className="px-4 py-3 text-right">Shipping</th>
<th className="px-4 py-3 text-right">Qty</th>
</tr>
</thead>
<tbody>
{listings.map((listing, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-3">{listing.sellerName}</td>
<td className="px-4 py-3">{listing.condition}</td>
<td className="px-4 py-3 text-right">
${listing.price.toFixed(2)}
</td>
<td className="px-4 py-3 text-right">
{listing.shipping === 0
? 'Free'
: `$${listing.shipping.toFixed(2)}`}
</td>
<td className="px-4 py-3 text-right">
{listing.quantity}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Step 6: Card Detail Page
Put it all together on a card detail page:
// src/app/card/[id]/page.tsx
import { getPrices, getLiveListings } from '@/lib/tcgapis';
import PriceDisplay from '@/components/PriceDisplay';
import LiveListings from '@/components/LiveListings';
export default async function CardPage({
params
}: {
params: { id: string }
}) {
const [prices, listings] = await Promise.all([
getPrices(params.id),
getLiveListings(params.id),
]);
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Card Prices</h1>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">
Current Prices
</h2>
<PriceDisplay prices={prices} />
</section>
<section>
<h2 className="text-xl font-semibold mb-4">
Live Listings ({listings.length})
</h2>
<LiveListings listings={listings} />
</section>
</main>
);
}Step 7: Adding Sales History
Use the sales history endpoint to show price trends:
// src/components/SalesChart.tsx
'use client';
interface Sale {
orderDate: string;
condition: string;
purchasePrice: number;
quantity: number;
}
export default function SalesChart({
sales
}: {
sales: Sale[]
}) {
// Group sales by date for a simple visualization
const dailyPrices = sales.reduce((acc, sale) => {
const date = sale.orderDate.split('T')[0];
if (!acc[date]) {
acc[date] = { total: 0, count: 0 };
}
acc[date].total += sale.purchasePrice;
acc[date].count += 1;
return acc;
}, {} as Record<string, { total: number; count: number }>);
const chartData = Object.entries(dailyPrices)
.map(([date, data]) => ({
date,
avgPrice: data.total / data.count,
}))
.sort((a, b) => a.date.localeCompare(b.date));
return (
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-4">Price History</h3>
<div className="flex items-end gap-1 h-40">
{chartData.slice(-30).map((point) => {
const max = Math.max(...chartData.map(d => d.avgPrice));
const height = (point.avgPrice / max) * 100;
return (
<div
key={point.date}
className="flex-1 bg-blue-500 rounded-t"
style={{ height: `${height}%` }}
title={`${point.date}: $${point.avgPrice.toFixed(2)}`}
/>
);
})}
</div>
</div>
);
}Tips for Production
Caching Strategy
Use Next.js built-in caching with revalidate to avoid hitting rate limits:
// In server components or API routes
const data = await fetch(url, {
next: { revalidate: 600 } // Revalidate every 10 minutes
});Error Boundaries
Wrap API-dependent components in error boundaries for a graceful failure experience:
// src/app/card/[id]/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-12">
<h2 className="text-xl font-semibold mb-2">
Something went wrong
</h2>
<p className="text-gray-500 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Try again
</button>
</div>
);
}Rate Limit Monitoring
Track your API usage in your dashboard and consider upgrading plans as your app grows. The Hobby tier gives you 10,000 requests per month — plenty for development and small projects.
Next Steps
This tutorial covers the basics. To take your tracker further, consider:
- Adding user accounts so users can save favorite cards
- Implementing price alerts via email or push notifications
- Building a portfolio tracker that calculates total collection value
- Adding comparison views for multiple cards or printings
The TCGAPIs gives you all the data you need to build comprehensive TCG tools. The only limit is your imagination.
Want to explore more? Try analyzing MTG market trends or learn how to automate card shop inventory with similar techniques.