Back to Blog
Tutorial|2026-02-08|15 min read

Building a Price Tracker with TCG API in 30 Minutes

A step-by-step tutorial on building a real-time card price tracker using TCG API, Next.js, and a few lines of JavaScript. From zero to deployed in half an hour.

Build a TCG Price Tracker in 30 Minutes

In this tutorial, we'll build a complete card price tracker that lets you search for any trading card and see live prices from both TCGPlayer and eBay — broken down by condition and grade. By the end, you'll have a working Next.js application backed by real market data, with charts, error handling, caching, and a path toward advanced features like price alerts.

What We're Building

A Next.js app with:

  • A search bar that queries the TCG API across all supported games
  • Price cards showing TCGPlayer and eBay data per condition (NM through Damaged)
  • Graded price display for PSA, BGS, and CGC values
  • A historical price chart powered by the /history endpoint
  • Caching to stay within API rate limits
  • Clean, responsive UI using Tailwind CSS

Prerequisites

  • Node.js 18+ installed
  • A free TCG API key (sign up at tcgpricelookup.com)
  • Basic JavaScript/React knowledge
  • Familiarity with async/await and REST APIs

Step 1: Set Up the Project

npx create-next-app@latest price-tracker
cd price-tracker

Choose TypeScript, Tailwind CSS, and the App Router when prompted. Then install a charting library for the price history section:

npm install recharts

Create a .env.local file in the project root:

TCG_API_KEY=your_api_key_here

Step 2: Create the API Helper

Create lib/tcg-api.ts with utility functions that talk to TCG API:

const API_BASE = "https://api.tcgpricelookup.com/v1";

function getHeaders() {
  return {
    Authorization: `Bearer ${process.env.TCG_API_KEY}`,
    "Content-Type": "application/json",
  };
}

export async function searchCards(query: string, game?: string) {
  const params = new URLSearchParams({ q: query });
  if (game) params.set("game", game);

  const res = await fetch(`${API_BASE}/cards/search?${params}`, {
    headers: getHeaders(),
    next: { revalidate: 300 }, // Cache for 5 minutes (Next.js App Router)
  });

  if (!res.ok) {
    throw new Error(`Search failed: ${res.status} ${res.statusText}`);
  }

  return res.json();
}

export async function getCardPrices(cardId: string) {
  const res = await fetch(`${API_BASE}/cards/${cardId}/prices`, {
    headers: getHeaders(),
    next: { revalidate: 300 },
  });

  if (!res.ok) {
    throw new Error(`Price fetch failed: ${res.status} ${res.statusText}`);
  }

  return res.json();
}

export async function getCardHistory(cardId: string, days = 90) {
  const params = new URLSearchParams({ days: String(days) });
  const res = await fetch(`${API_BASE}/cards/${cardId}/history?${params}`, {
    headers: getHeaders(),
    next: { revalidate: 3600 }, // History changes less often — cache 1 hour
  });

  if (!res.ok) {
    throw new Error(`History fetch failed: ${res.status} ${res.statusText}`);
  }

  return res.json();
}

Step 3: Understand the API Response Shape

Before building the UI, it helps to know exactly what the API returns. Here's the shape of a card search result:

{
  "data": [
    {
      "id": "pkmn-sv3-054",
      "name": "Charizard ex",
      "game": "pokemon",
      "set": "Obsidian Flames",
      "number": "054/197",
      "rarity": "Double Rare",
      "imageUrl": "https://cdn.tcgpricelookup.com/images/pkmn-sv3-054.jpg"
    }
  ],
  "meta": {
    "total": 1,
    "page": 1,
    "perPage": 20
  }
}

And the prices response for a single card:

{
  "cardId": "pkmn-sv3-054",
  "updatedAt": "2026-02-08T14:32:00Z",
  "tcgplayer": {
    "nearMint":        { "market": 38.50, "low": 35.00, "mid": 39.00, "high": 55.00 },
    "lightlyPlayed":   { "market": 32.00, "low": 28.00, "mid": 33.00, "high": 45.00 },
    "moderatelyPlayed":{ "market": 24.00, "low": 20.00, "mid": 25.00, "high": 35.00 },
    "heavilyPlayed":   { "market": 16.00, "low": 12.00, "mid": 17.00, "high": 25.00 },
    "damaged":         { "market":  8.00, "low":  5.00, "mid":  9.00, "high": 14.00 }
  },
  "ebay": {
    "nearMint": {
      "avg1Day":  40.25,
      "avg7Day":  39.80,
      "avg30Day": 41.10
    },
    "lightlyPlayed": {
      "avg1Day":  33.00,
      "avg7Day":  32.50,
      "avg30Day": 33.80
    }
  },
  "graded": {
    "psa": {
      "10": { "avg1Day": 185.00, "avg7Day": 178.00, "avg30Day": 182.00 },
      "9":  { "avg1Day":  75.00, "avg7Day":  72.00, "avg30Day":  74.00 },
      "8":  { "avg1Day":  48.00, "avg7Day":  46.00, "avg30Day":  47.50 }
    },
    "bgs": {
      "10": { "avg1Day": 220.00, "avg7Day": 215.00, "avg30Day": 218.00 },
      "9.5":{ "avg1Day": 110.00, "avg7Day": 108.00, "avg30Day": 109.00 }
    },
    "cgc": {
      "10": { "avg1Day": 130.00, "avg7Day": 128.00, "avg30Day": 131.00 }
    }
  }
}

Knowing this structure up front saves a lot of trial and error when building your components.

Step 4: Build the Search Component

Create components/CardSearch.tsx:

"use client";

import { useState } from "react";

interface CardResult {
  id: string;
  name: string;
  game: string;
  set: string;
  number: string;
  imageUrl: string;
}

export function CardSearch({ onSelect }: { onSelect: (id: string) => void }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<CardResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSearch(e: React.FormEvent) {
    e.preventDefault();
    if (!query.trim()) return;

    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      if (!res.ok) throw new Error("Search request failed");
      const data = await res.json();
      setResults(data.data ?? []);
    } catch (err) {
      setError("Search failed. Please try again.");
      setResults([]);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="w-full max-w-xl">
      <form onSubmit={handleSearch} className="flex gap-2 mb-4">
        <input
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search for a card..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          type="submit"
          disabled={loading}
          className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {loading ? "Searching..." : "Search"}
        </button>
      </form>

      {error && <p className="text-red-600 text-sm mb-4">{error}</p>}

      <ul className="space-y-2">
        {results.map((card) => (
          <li
            key={card.id}
            onClick={() => onSelect(card.id)}
            className="flex items-center gap-3 p-3 border rounded cursor-pointer hover:bg-gray-50"
          >
            <img src={card.imageUrl} alt={card.name} className="w-10 h-14 object-contain" />
            <div>
              <p className="font-semibold">{card.name}</p>
              <p className="text-sm text-gray-500">{card.set} · #{card.number}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 5: Display Price Data Per Condition

Create components/PriceTable.tsx to render the condition-by-condition breakdown:

interface ConditionPrices {
  market: number;
  low: number;
  mid: number;
  high: number;
}

interface EbayPrices {
  avg1Day: number;
  avg7Day: number;
  avg30Day: number;
}

const CONDITIONS = [
  { key: "nearMint", label: "Near Mint" },
  { key: "lightlyPlayed", label: "Lightly Played" },
  { key: "moderatelyPlayed", label: "Moderately Played" },
  { key: "heavilyPlayed", label: "Heavily Played" },
  { key: "damaged", label: "Damaged" },
] as const;

export function PriceTable({ tcgplayer, ebay }: { tcgplayer: any; ebay: any }) {
  return (
    <table className="w-full text-sm border-collapse">
      <thead>
        <tr className="bg-gray-100">
          <th className="text-left p-2">Condition</th>
          <th className="text-right p-2">TCG Market</th>
          <th className="text-right p-2">eBay 1-Day</th>
          <th className="text-right p-2">eBay 7-Day</th>
          <th className="text-right p-2">eBay 30-Day</th>
        </tr>
      </thead>
      <tbody>
        {CONDITIONS.map(({ key, label }) => {
          const tcg: ConditionPrices = tcgplayer[key] ?? {};
          const eb: EbayPrices = ebay[key] ?? {};
          return (
            <tr key={key} className="border-t">
              <td className="p-2">{label}</td>
              <td className="text-right p-2">${tcg.market?.toFixed(2) ?? "—"}</td>
              <td className="text-right p-2">${eb.avg1Day?.toFixed(2) ?? "—"}</td>
              <td className="text-right p-2">${eb.avg7Day?.toFixed(2) ?? "—"}</td>
              <td className="text-right p-2">${eb.avg30Day?.toFixed(2) ?? "—"}</td>
            </tr>
          );
        })}
      </tbody>
    </table>
  );
}

Step 6: Display Graded Prices

For graded cards, show a separate section with PSA, BGS, and CGC breakdowns. Create components/GradedPrices.tsx:

const GRADERS = ["psa", "bgs", "cgc"] as const;
const COMMON_GRADES = ["10", "9.5", "9", "8"] as const;

export function GradedPrices({ graded }: { graded: any }) {
  return (
    <div className="grid grid-cols-3 gap-4 mt-6">
      {GRADERS.map((grader) => {
        const data = graded[grader];
        if (!data) return null;

        return (
          <div key={grader} className="border rounded p-3">
            <h3 className="font-bold uppercase mb-2">{grader}</h3>
            {COMMON_GRADES.map((grade) => {
              const prices = data[grade];
              if (!prices) return null;
              return (
                <div key={grade} className="flex justify-between text-sm py-1 border-t">
                  <span>{grader.toUpperCase()} {grade}</span>
                  <span className="font-medium">${prices.avg30Day?.toFixed(2) ?? "—"}</span>
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

Step 7: Add Price History Charts

The history endpoint returns daily price snapshots. Use Recharts to visualize trends:

"use client";

import {
  LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from "recharts";

interface HistoryPoint {
  date: string;
  tcgMarket: number;
  ebayAvg: number;
}

export function PriceHistoryChart({ history }: { history: HistoryPoint[] }) {
  return (
    <div className="mt-8">
      <h2 className="text-lg font-semibold mb-4">Price History (90 Days)</h2>
      <ResponsiveContainer width="100%" height={280}>
        <LineChart data={history}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="date" tick={{ fontSize: 11 }} />
          <YAxis tickFormatter={(v) => `$${v}`} />
          <Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
          <Legend />
          <Line
            type="monotone"
            dataKey="tcgMarket"
            stroke="#3b82f6"
            name="TCGPlayer Market"
            dot={false}
          />
          <Line
            type="monotone"
            dataKey="ebayAvg"
            stroke="#f59e0b"
            name="eBay 30-Day Avg"
            dot={false}
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

The chart immediately shows whether TCGPlayer and eBay prices are converging, diverging, or tracking together — which is a useful signal when deciding whether to buy or sell.

Step 8: Error Handling and Resilience

Production apps need solid error handling. A few patterns worth implementing:

Rate limit detection. The API returns a 429 status when you exceed your plan's daily limit. Catch it explicitly and show a helpful message:

if (res.status === 429) {
  throw new Error("Daily API limit reached. Resets at midnight UTC.");
}

Graceful degradation. If eBay data is unavailable for a condition (the API returns null for some low-volume conditions), render a dash rather than crashing. The null checks in the components above handle this.

Retry with exponential backoff. For transient failures, a simple retry wrapper helps:

async function fetchWithRetry(url: string, options: RequestInit, retries = 2): Promise<Response> {
  try {
    const res = await fetch(url, options);
    if (res.status >= 500 && retries > 0) {
      await new Promise((r) => setTimeout(r, 500 * (3 - retries)));
      return fetchWithRetry(url, options, retries - 1);
    }
    return res;
  } catch (err) {
    if (retries > 0) {
      await new Promise((r) => setTimeout(r, 500));
      return fetchWithRetry(url, options, retries - 1);
    }
    throw err;
  }
}

Step 9: Caching Strategies

The free tier gives you 200 requests per day, so caching matters. A few approaches:

Next.js built-in cache. The next: { revalidate: N } option on fetch() caches responses in the Next.js data cache. 300 seconds (5 minutes) is a good default for price data. History data, which changes less frequently, can be cached for an hour or more.

In-memory cache for the browser. For client-side requests, a simple Map prevents repeat fetches within a session:

const cache = new Map<string, { data: any; timestamp: number }>();
const TTL = 5 * 60 * 1000; // 5 minutes

async function getCachedPrices(cardId: string) {
  const cached = cache.get(cardId);
  if (cached && Date.now() - cached.timestamp < TTL) {
    return cached.data;
  }
  const data = await getCardPrices(cardId);
  cache.set(cardId, { data, timestamp: Date.now() });
  return data;
}

Redis for multi-user apps. If you're building an app where many users search the same popular cards, a shared Redis cache dramatically reduces API calls. Cache the raw API response keyed by card ID and condition.

Comparing TCGPlayer vs eBay Prices

The side-by-side comparison is one of the most useful features you can build. Here are some patterns worth highlighting in your UI:

  • eBay above TCGPlayer signals high demand. The card may be underpriced on TCGPlayer relative to what the open market will bear.
  • TCGPlayer above eBay can indicate that the TCGPlayer market is slow to correct downward. Common after a card rotates out of a competitive format.
  • Tight spread (within 5-10%) means the market is liquid and efficient. Both platforms agree on value.
  • Wide spread on graded copies (e.g., a PSA 10 priced at $500 on TCGPlayer but only selling for $380 on eBay) is a strong sell signal.

TCG Price Lookup surfaces these comparisons instantly without writing a line of code — useful for quick lookups while the full tracker is a better fit for bulk analysis or portfolio monitoring.

Step 10: Wire Everything Together

Create your main page at app/page.tsx:

"use client";

import { useState } from "react";
import { CardSearch } from "@/components/CardSearch";
import { PriceTable } from "@/components/PriceTable";
import { GradedPrices } from "@/components/GradedPrices";
import { PriceHistoryChart } from "@/components/PriceHistoryChart";

export default function HomePage() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [prices, setPrices] = useState<any>(null);
  const [history, setHistory] = useState<any[]>([]);

  async function handleSelect(cardId: string) {
    setSelectedId(cardId);
    const [priceData, historyData] = await Promise.all([
      fetch(`/api/prices/${cardId}`).then((r) => r.json()),
      fetch(`/api/history/${cardId}`).then((r) => r.json()),
    ]);
    setPrices(priceData);
    setHistory(historyData.data ?? []);
  }

  return (
    <main className="max-w-3xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">TCG Price Tracker</h1>
      <CardSearch onSelect={handleSelect} />
      {prices && (
        <>
          <PriceTable tcgplayer={prices.tcgplayer} ebay={prices.ebay} />
          <GradedPrices graded={prices.graded} />
          <PriceHistoryChart history={history} />
        </>
      )}
    </main>
  );
}

Next Steps

Once the basic tracker is working, here's where you can take it further:

Price alerts via webhooks. The Business plan includes a WebSocket feed that pushes price changes in real time. You can combine this with email or push notifications to alert users when a card hits their target price.

Portfolio tracking. Add authentication (NextAuth.js works well) and let users save cards with quantities and purchase prices. Calculate total collection value and unrealized gains/losses.

Graded card analytics. Use the graded price data to build a "grading calculator" — input a card's raw price, select a grader, and see the expected return at each grade level minus grading fees. This is genuinely useful for collectors deciding whether to submit.

Multi-game comparison. The API covers 8+ games, so you can build dashboards that compare price trends across Pokemon, MTG, and One Piece simultaneously. Useful for investors looking to allocate across the TCG market.

Sold-price prediction. Combine 30-day eBay averages with the price history endpoint to build simple linear trend projections. Not financial advice — but a useful visualization.

Export to spreadsheet. Add a CSV export button so users can pull price snapshots into Google Sheets for their own analysis.

The free tier gives you 200 requests per day — more than enough to build and test the full app described here. When you're ready to scale or need graded prices and price history, the Pro plan bumps you to 10,000 requests per day. You can also use TCG Price Lookup directly to manually verify the prices your app is pulling during development.

Happy building!