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