This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A card browser with debounced filtering, sets index, per-set grids, and full card detail pages — built on the TCGdex SDK behind a same-origin proxy.
Update — August 5, 2026
A whole-project maintainability pass reached the TCG routes. The three list endpoints (series, sets, cards) each hand-wrote the same block: call the TCGdex SDK, turn a thrown error or a null result into a 502, strip the SDK's circular refs with toPlain, and forward the JSON with an identical cache header. That's now one serveTcg(errorLabel, produce) helper, and each route is a one- or few-liner that passes a producer thunk.
Where it lives, and why. The helper sits in a server-only lib/tcg-route.ts, deliberately not in lib/tcg.ts — that module is imported by client components (typeStyle, the type colors), and putting next/server there would drag it into the client bundle. Same bundling trap the refactor's backend-URL step fixed, avoided up front here.
What I did not fold in. The two detail routes (sets/[setId], cards/[cardId]) return 404 on a miss, not 502, and deliberately don't catch — a thrown error should surface as a 500, not be dressed up as an upstream failure. Forcing them through the list helper would have quietly changed that, so they keep their own handler and only adopt the shared cache constant. The tradeoff is two routes that don't share the wrapper, which is the correct price for not lying about a 500. Characterization tests written against the old routes still pass, so the swap is provably behaviour-preserving.
The app's CSP locks connect-src to same-origin, so the browser can't reach api.tcgdex.net directly. Every SDK call lives in a Next.js API route — the browser talks to /api/tcg/cards, the route calls the SDK, returns plain JSON. The SDK also carries a circular sdk property on model instances — a toPlain() helper strips those keys before serialization.
The set detail page is a server component — it calls the SDK at render time for metadata, logo, release year, and legality. The card grid needs pagination so it's a client component calling the API route. The set detail server component wraps the grid in a Suspense boundary, which is required by Next.js App Router for any client component that calls useSearchParams during server rendering.
The browse page fetches page 1 server-side — one module-level TCGdex SDK instance per server process, same as the API route. A BrowseSkeleton wraps it in a Suspense boundary so the skeleton streams immediately while the fetch resolves. BrowseContent gets initialCards as a prop and skips the page-1 fetch — but only when the URL has no active filters.
A sentinel div at the bottom of the list is watched by an IntersectionObserver. The tricky part: observers only fire on intersection state changes. On a wide screen the sentinel might already be visible after the first load — the state never changes, so it never fires again. The fix: add cards.length to the observer effect's dependency array. That reconnects the observer after every fetch, which calls observe() fresh and immediately reports current intersection state.
After converting to useInfiniteQuery, manual AbortController, loadedPages state, and the scroll restore loop are all gone. initialPageParam reads ?page=N from the URL on mount, getNextPageParam increments from there, and the sentinel observer calls fetchNextPage() guarded by hasNextPage.
Search, type filter, and page number all live in the URL as query params. On mount the component reads the URL to initialize state. As the user types or scrolls, the URL updates with router.replace(..., { scroll: false }). Back and forward navigation changes the URL, which syncs back into state. Sharing ?q=pikachu&type=Psychic&page=4 restores exactly that view.
Rapid filter changes can cause a race — an old fetch resolving after the new one starts overwrites the results. The original fix was an AbortController pattern where each fetch aborts the previous one before starting, with AbortError caught and silently ignored. After converting to useInfiniteQuery, TanStack handles this automatically — when the query key changes it cancels the in-flight request via its own abort signal. No more abortRef or AbortError catch needed.
Update — August 10, 2026
The card browser renders thousands of cards, and the honest answer for a long time was that it rendered all of them. Virtualising the lists was the fix, and memoising the card content was the other half — virtualisation stops you paying for off-screen rows, memoisation stops you paying again for on-screen rows that did not change. Doing one without the other leaves most of the win on the table.
The Pocket page is gated on a feature flag, which made this the first place the flags console had a real consumer rather than demo data. That is the part I would keep: a flag system with nothing behind it is a demo of a flag system. Wiring one real page to it turned the console into something that can be wrong, which is what made it worth building carefully.
Update — August 30, 2026
These pages read from our own database now instead of calling TCGdex while they render. Getting there took three wrong diagnoses, and the wrong ones are the useful part.
The set list had been showing nothing for a day and read as data nobody had refreshed. It was not. The page fetched TCGdex at render time, caught the failure, rendered an empty list — and ISR then cached that empty list for twenty-four hours. An outage and a quiet Tuesday produce identical HTML, which is the whole problem: the page had one way of saying “nothing” and two very different reasons for saying it.
The nightly went red two nights running with no test failure in it:
[WebServer] Export encountered an error on /tcg/pokemon/sets/[setId]/page: /tcg/pokemon/sets/me02, exiting the build. Error: Process from config.webServer was not able to start. Exit code: 1
next build exited 1, so Playwright never got a server and the job died before running anything. Every mock in the suite was irrelevant, because none of it happened inside a test.
First I guarded the nested fields a newly announced set arrives without — set.cardCount.official and friends. Genuine crashes, genuine tests. Then it failed again on a different set, on a branch that already had that fix, so I removed the build-time pre-render instead. The failure moved one route over, to the sets list. That felt like progress and was still not the answer.
GitHub's log only ever showed “Export encountered an error”. Vercel's said what actually happened:
Failed to build /tcg/pokemon/sets/page (attempt 1 of 3) because it took more than 60 seconds. Retrying... Failed to build /tcg/pokemon/sets/page after 3 attempts.
A timeout, not a crash. That page lists every series and then fetches each one, so its build cost is a multiple of however slow TCGdex is that minute. It explains the property that made it so slippery: it was intermittent, and a re-run usually cleared it, because the failure tracked upstream latency rather than anything in the diff. It reads as flake, and flake gets dismissed. I had two plausible mechanisms with evidence and passing tests for each, and none of that made either one the cause.
A cron in portfolio_api mirrors the series and sets into Postgres, and these pages read /api/tcg/catalog. The fan-out happens once, on a schedule, where being slow costs nothing. The ingest assembles the whole catalog before writing any of it and commits in one transaction, upserts instead of deleting, and refuses an empty series list outright — every one of those rules exists to stop it producing the empty page that started this. And the pages now distinguish “could not load” from “nothing ingested yet”, which is the distinction whose absence hid the outage for a day.
The cron's first production run said fetch failed, and their status page said everything was fine. Both were true:
dig +short A api.tcgdex.net # 142.44.242.175 (refuses, instantly) curl --resolve api.tcgdex.net:443:217.182.193.43 https://api.tcgdex.net/v2/en/series # 200, valid cert, real data
The API is healthy on the same box that serves their docs and assets, holding a certificate whose only name is api.tcgdex.net. The public A record points somewhere else, at a host that refuses connections. Anyone resolving DNS normally gets sent to the dead one, which is why it was down for us and up for them. I had told Paul the API was down; the honest version is that I stopped at “unreachable” instead of asking why, and the difference matters, because “down” means wait and this means tell them.
Which is the argument for the whole change, made better than I could have made it: the pages now fail in a way that says what is wrong, and a third party can no longer break the build at all.
Update — August 31, 2026
The catalog is populated — 21 series, 218 sets. Getting there meant deciding to route around someone else's infrastructure, which is not a decision I wanted to make on a hunch.
Someone had filed it two days earlier: GeoDNS still serves dead North America node 142.44.242.175. A maintainer closed it the same day:
na is experiencing outages, we cant just drop a node like that
That reframed it. A deliberate decision is not an outage to wait out, and it also meant filing my own report would have been noise. It did correct my diagnosis though: I had called it a wrong DNS record, and it is GeoDNS — North America gets the dead node while other regions get healthy ones, which is exactly why their status page reads green and everything we deploy on fails.
After the published address fails its retries, the ingest now tries nodes that do answer, using node:https with a fixed lookup. The URL still drives SNI and certificate validation, so this is curl's --resolve rather than a way of skipping the checks. It engages only after the normal path fails, so the day their DNS is fixed we go straight back to it, and the addresses live in an environment variable because they will move.
The first production run:
fallback node failed too ip: 51.68.233.163 error: Invalid IP address: undefined fallback node failed too ip: 217.182.193.43 error: Invalid IP address: undefined
Node calls a custom lookup two different ways. With all: true it wants an array of {address, family}; otherwise it wants (err, address, family). Since Node 20 autoSelectFamily is on and the socket asks for all, so answering only the second way puts undefined into the connection. Both nodes were healthy the entire time.
I had written in that pull request that the fallback was not unit tested, because mocking a socket would only assert that my mock works. True, and the wrong conclusion — the bug landed in the only novel code with no coverage. The call shapes are pure logic and now have tests, and the whole path was runnable against a real node all along, which is how I confirmed the fix rather than assuming it.
Worse, three existing tests had been passing because the fallback was broken: every attempt died instantly, so tests that made the primary fail never noticed they would otherwise reach the real internet. Fixing the bug turned them into network calls. A test that reaches the internet to prove a failure is not testing the failure.
The first successful run took two and a half minutes, because every one of its twenty-two requests re-ran three attempts and two backoffs against an address it had watched fail seconds earlier. The run now remembers the node that answered — per run, never persisted, so every run re-checks the published address and the day their GeoDNS is fixed we notice and stop routing around it.