tutorials6 min read

Build a Pokemon Card Price Tracker with Next.js and TCGAPIs

Step-by-step tutorial on building a Pokemon card price tracker using Next.js and the TCGAPIs pricing API. Track prices, view trends, and monitor your collection.

T

TCGAPIs Team

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-tracker

Create a .env.local file for your API key:

TCGAPIS_KEY=your-api-key-here
TCGAPIS_BASE_URL=https://api.tcgapis.com/api/v1

Step 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.

Pokemonprice trackerNext.jstutorialPokemon card API