← Go Back
How It Is Built

Tech notes

A small application, deliberately. Six tables carry the whole domain, matching is one SQL statement, and no card moves between two people until both of them say it happened.

01

The stack

FrameworkNext.js 16, App Router. Server Components by default; the card pages are client components because they are one long editing session.
LanguageTypeScript, strict, no any. Domain values are string-literal unions rather than enums, and the database CHECK constraints spell the same words.
DatabasePostgres on Neon, addressed with a tagged template that parameterises every value. No ORM, no query builder — the SQL in the route is the SQL that runs.
AuthNextAuth v5. Passwords are bcrypt hashes; the session carries a user id and nothing else.
HostingVercel.
StylingEmotion and inline styles, both reading the same design tokens. Black, white, one grey, monospace — no colour, no rounded corners, no shadows.

Schema changes go through numbered SQL migrations applied in order, each in its own transaction. There are two database branches — the live one, and a copy-on-write fork of it that local work and every migration rehearsal run against. A migration reaches production only after it has run on the fork, and migrations are additive by habit because the fork gets reset from its parent.

02

The data model

A user owns exactly three lists, and every card belongs to one of them. That constraint is in the schema, not in the application: a unique index on (user_id, type) makes a fourth wantlist impossible to write.

users └── lists type ∈ wantlist | bulk | collection └── cards one row per holding scryfall_cards local snapshot of every printing exchanges a trade, pending until both confirm password_reset_tokens a signed link, until it is used

A card row is a holding, not a copy: a printing plus language, quality and foil, with a quantity. Four identical Lightning Bolts are one row with qty 4. Those seven columns are exactly the unique index adding a card upserts against, so adding a card you already have increments it instead of making a duplicate.

Deletes cascade down the tree. Removing an account removes its lists, which removes their cards; a reservation pointing at a deleted user goes null rather than dangling.

03

How matching is computed

One statement, run when you ask and thrown away afterwards. No match table, no nightly job, nothing to go stale.

their bulk ∩ my wantlist → "they have" their wantlist ∩ my bulk → "they want" joined on LOWER(card_name) across every public profile reservations for someone else excluded

The join is on name alone. Set, collector number, language, quality and foil are all ignored, because you will be holding the actual card when you meet and that is the moment to judge it. It also means an “any set” want matches every printing without needing a special case.

Matching is directional and each direction is counted separately: how well you match someone is a different number from how well they match you. It is not scoped by community — narrowing it would hide trades rather than find them. Community decides who may see your contact details, not who you match.

The query is the expensive thing the app does, and it is not rate limited. Three routes are — signing in, card search and feedback — and those are limited by IP address, which is the blunt version: a whole game store behind one connection shares a single search budget. Both of those are worth fixing, and neither is fixed yet.

04

Where card data comes from

Everything about a card — names, printings, collector numbers, artwork, prices — comes from Scryfall. None of it is ours and none of it is authored here.

Printings are kept in a local table seeded from Scryfall's bulk snapshot, so autocomplete and printing lookups are a database query rather than a round trip. Live calls are reserved for what the snapshot cannot answer — current prices, and the set catalogue — and are batched: opening a binder page asks about its slots in one request, not nine. Images are fetched through a proxy on this domain so no page load reports your browsing to a third party.

Not affiliated with or endorsed by Wizards of the Coast. Magic: The Gathering is a trademark of Wizards of the Coast LLC.

05

What is written down

Your account, your three lists and their cards, your community, and every exchange — including one still waiting on a confirmation, since a promise you can withdraw has to be written down to be withdrawn. That is the whole of it.

Not stored: match results, who looked at whose profile, what you searched for, or any analytics of how you use the site. A match exists for the length of one request.

Your collection is never public in any view, and is never read by the match engine — it is a private ledger that happens to live next to two public ones. Contact details are a separate switch from profile visibility, so listing cards never implies publishing your phone number. Any list exports as CSV, and account deletion is a real delete. The privacy policy has the formal version.

06

Choices worth naming

Raw SQL. The queries here are joins across four tables with real conditions in them. An ORM would hide the part worth reading and add a layer to debug when a match comes out wrong.

No prices in the matching. Values are shown where they help you decide and never summed into a verdict. A trade is two people agreeing, not a spreadsheet clearing.

Names, not printings. Matching on exact printings would be more precise and much less useful — most people want the card, not the printing.

Confirmation from both sides. An exchange completes only when both parties record it. One person marking a trade done is a claim, not a fact.

What it is, and how to use it →What's changed →Privacy policy →