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.