apps
Build a Pokémon card collection app
Mirror the catalogue once, keep it current with a change feed, and value a binder without hammering the API on every screen.
Who this is for
You are building a collection tracker, a binder app, or an inventory tool for a shop, and you need the catalogue available offline and current.
Plan you need
The free tier covers building and running a small app. Bulk snapshots, which make the initial import one download instead of many pages, are a paid feature.
A collection app is a mirror with opinions. The user browses sets, marks what they own, and expects the app to feel instant — which means the catalogue has to be local. It also has to be current, because a set releasing and not appearing for two days is the kind of thing that gets a one-star review. Those two requirements pull in opposite directions unless you have a way to fetch only what changed.
There are two supported ways to get the catalogue in. Paging every card works and needs nothing but the search endpoint; a bulk snapshot is one signed download of the whole thing and is what you actually want if you are doing it on a schedule. After that, both converge on the same maintenance loop: poll an append-only feed of every mutation, apply the rows you have not seen, and store the highest id you processed.
The code
The two halves of a sync: an initial import that pages a set at a time, and an incremental catch-up that reads the change feed from the last id you stored.
const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;
async function api(url) {
const res = await fetch(url.startsWith("http") ? url : BASE + url, {
headers: { "X-Api-Key": KEY },
});
if (!res.ok) throw new Error(res.status + " " + (await res.text()));
return res.json();
}
// Initial import, one set at a time. Follow links.next; never rebuild the URL
// yourself — the cursor carries a signature of the sort order.
export async function importSet(code, db) {
let next =
"/v1/sets/" + code + "/cards?limit=250&include=images&select=" +
"id,name,number,number_sort,rarity,supertype,subtypes,types,hp,artist_name";
while (next) {
const page = await api(next);
await db.upsertCards(page.data);
next = page.links?.next ?? null;
}
}
// Incremental sync. One integer is a complete resume position.
export async function catchUp(db) {
let since = await db.getSyncCursor(); // e.g. 4821993
for (;;) {
const page = await api(
"/v1/changes?since=" + since + "&kind=CARD,SET,PRICE,IMAGE&limit=250",
);
if (page.data.length === 0) break;
for (const change of page.data) {
// Process strictly in id order; changed_at is not unique.
if (change.op === "DELETE") await db.remove(change.kind, change.entity_id);
else await db.refetch(change.kind, change.entity_id);
since = change.id;
}
await db.setSyncCursor(since);
if (!page.meta.has_more) break;
}
}Mirror the catalogue, do not proxy it
Every screen in a collection app is a query over the catalogue: cards in a set, cards by type, cards this user owns, cards missing from a set. Answering those from a remote API means a network round trip per scroll, which is both slow and expensive. Import once into your own store and every one of those becomes a local query you can index however your UI needs.
Import a set at a time and keep the set boundary in your own schema. Sets are the unit users think in, the unit releases arrive in, and the unit a partial import can safely resume from. Ask for the fields your app actually renders — a binder grid needs an id, a name, a number and an image, not the full rules text of every card.
Stay current with the change feed
The change feed is an append-only stream of every catalogue mutation, written by database triggers rather than by application code, so nothing can be modified without appearing in it. Each entry carries a monotonic id, the kind of entity, its id, and whether it was created, updated or deleted. You store one integer — the highest id you have applied — and that integer is a complete description of how far along you are.
Do not use timestamps as the cursor. Two rows can share a changed_at, and a cursor built on a non-unique value either repeats work or skips it, depending on which comparison you picked. Poll every few minutes if you want a near-live mirror, or once an hour if you do not; either way the feed picks up exactly where you left off, and a full re-import stops being something you ever need to do again.
Value a binder without lying about it
Collection value is the feature users open the app for and the one most likely to make them angry. Two rules keep it honest. Show the date: a total is a snapshot of a specific day, and labelling it as such prevents the assumption that it is live. Show the method: if the number comes from a derived index, say so, and let a user tap through to see the sample size behind it.
Handle unpriced cards explicitly. Some printings have no servable observation — too obscure, too new, or a source that was withdrawn. Silently treating those as zero produces a total that is wrong in a direction users notice. Listing them as unpriced produces a total that is smaller and true, plus a list the user can act on.
What usually goes wrong
Not a disclaimer list: these are the failure modes that cost people time on this specific integration.
- Re-importing everything on a schedule
- A nightly full import is thousands of requests to discover that a dozen rows changed. Take a snapshot once, then follow the change feed. It is cheaper for you and it is the difference between a sync that takes seconds and one that takes an hour.
- Rebuilding the next-page URL by hand
- Follow links.next verbatim. The cursor encodes the sort order, so a hand-assembled URL with a different orderBy is rejected rather than quietly returning a page from a different ordering — which is the failure you want, but only if you were not expecting it.
- Caching prices as long as cards
- Card data is stable for months; prices change daily. One cache policy for both means either stale valuations or pointless catalogue refetches. Keep them in separate tables with separate refresh rules.
Endpoints used
- GET
/v1/setsEnumerate sets to import, with totals, release dates and print region. - GET
/v1/sets/{code}/cardsPage a whole set in collection order, stable past 250 rows. - GET
/v1/changesAppend-only mutation feed: resume from the last id you processed. - GET
/v1/bulkSigned whole-catalogue snapshots, verified by sha256 and row count. - GET
/v1/cards/batchHydrate up to 100 owned card ids per request.