Comparison Tables Rot: Keeping "X vs Y" Pages Honest With a Typed Fact Sheet
"X vs Y" comparison pages are mostly hand-written tables, and hand-written tables rot. Exchanges re-enter countries, change fees, get hacked and leave markets, and nobody goes back to page 37 to fix the sentence that is
"X vs Y" comparison pages are mostly hand-written tables, and hand-written tables rot. Exchanges re-enter countries, change fees, get hacked and leave markets, and nobody goes back to page 37 to fix the sentence that is no longer true.
In October I checked the crypto comparison pages on my own site against each exchange's own pages. These sentences were wrong:
- "Bybit has never experienced a major hack." About $1.5B of ETH was stolen from a Bybit cold wallet in February 2025. Client funds were covered.
- "UK users cannot access Bybit." Bybit relaunched in the UK in December 2025 with spot trading and P2P.
- "Kraken is licensed across all 50 states." Kraken doesn't serve New York or Maine.
- "MEXC charges 0.1%." MEXC's spot fees are 0% maker and 0.05% taker.
- "MEXC is registered in Seychelles." MEXC's terms name MEXC (Comoros) Ltd as the operator.
- "Bybit charges 0.1%." Not if you live in the EEA: you would sign up with Bybit EU, which charges 0.25%.
Some had been true once. Some never were. All of them sat on pages where a reader had no way to tell which.
One fact sheet, typed
Every exchange now has one object, and every field in it is optional, because "we don't know" is a legitimate answer:
export interface ExchangeFacts {
name: string;
founded?: string;
us?: string; // "Yes — every state except New York and Maine"
eu?: string;
uk?: string;
licenses?: string;
reserves?: string; // proof of reserves: what, when, how much
incidents?: string; // security record
p2p?: string;
card?: string;
serves?: (country: string) => string | undefined;
sources: { label: string; url: string; read: string }[];
}
Three rules go with it:
- A fact goes in only after it is read on a primary source: the exchange's terms, fee page, help centre or blog, or a regulator. The source goes in with it, with the date it was read.
- A fact nobody could verify stays out. The table renders "—". An empty cell is honest; a plausible guess is how the six sentences above got written.
- Fees come from the fee calculator's module wherever it covers the exchange, so a rate is never typed twice. The fact sheet carries a fee only where the calculator has none, as for MEXC.
Kraken's entry, trimmed:
kraken: {
name: "Kraken",
founded: "2011",
us: "Yes — every state except New York and Maine",
eu: "Yes — MiCA license from the Central Bank of Ireland",
reserves: "Published since 2014; BTC 102.9%, ETH 100.5% on 30 Jun 2026",
incidents: "No customer funds lost to a hack; a 2024 bug cost Kraken about $3M of its own money, returned",
sources: [
{ label: "Kraken: where it is licensed or regulated", url: "https://support.kraken.com/articles/where-is-kraken-licensed-or-regulated", read: "28 Oct 2026" },
{ label: "Kraken Proof of Reserves", url: "https://www.kraken.com/proof-of-reserves", read: "25 Oct 2026" },
// …
],
},
Cells that depend on the reader
Some answers depend on where the reader is, so they are functions, not strings. MEXC's availability:
// MEXC's terms (read 29 Oct 2026) bar these countries
const MEXC_BARRED = new Set(["KP", "CU", "SD", "IR", "CN", "SG", "MY", "US", "GB", "HK", "KZ", "RU", "BY", "CA"]);
serves: (country) =>
MEXC_BARRED.has(country) ? "No — barred by its terms"
: country === "NL" ? "Leaving — accounts close 16 Nov 2026"
: EEA.has(country) ? "Not barred, but no MiCA license"
: "Not barred by its terms",
A reader in Amsterdam sees "Leaving — accounts close 16 Nov 2026", a reader in Berlin sees "Not barred, but no MiCA license", and a reader in Toronto sees "No — barred by its terms". The country comes from Cloudflare's cf-ipcountry header.
The fee row works the same way. In Germany, the OKX vs Bybit table shows OKX's EEA platform and Bybit EU, each under the name of the platform the reader would actually use:
The OKX vs Bybit table for a reader in Germany. OKX's security record shows "—": I haven't verified one, so the table doesn't pretend either way.
Rendering: rows as data
The component is a list of [label, cell] pairs, so a new row is one line and every row handles "unknown" the same way:
const rows: [string, (i: number) => ReactNode][] = [
[`Spot fee, limit / market${where ? ` (in ${where})` : ""}`, (i) => spotFee(cols[i])], // from feesFor()
["United States", (i) => facts[i]?.us ?? "—"],
["European Union", (i) => facts[i]?.eu ?? "—"],
["Proof of reserves", (i) => facts[i]?.reserves ?? "—"],
["Security record", (i) => facts[i]?.incidents ?? "—"],
// …
];
Under the table, the sources of both columns are printed with their read dates. They are deduplicated by URL, because one page can back facts in both columns (Kraken's post about taking over MEXC's Dutch users is a source for Kraken and for MEXC):
const sources = [...(facts[0]?.sources ?? []), ...(facts[1]?.sources ?? [])]
.filter((s, i, all) => all.findIndex((x) => x.url === s.url) === i);
The footnote under the Bybit vs Coinbase table: every fact links to the page it was read on, with the date.
What changed in practice
- One edit, every page. When MEXC's Dutch accounts close on 16 November, one line changes, and every table that compares MEXC follows.
- Contradictions surface. Building the tables meant checking every fact against a primary source, and that is how the six sentences above turned up. With the table at the top of the page, a sentence further down that disagrees with it now stands out in review.
- Readers can check every cell. Each one traces to a page and a date. On a page about where to keep your money, that matters more than any adjective.
The tables are live, and each shows the rows for the country you're in: OKX vs Bybit, Bybit vs Coinbase, Kraken vs MEXC and Kraken vs Coinbase.
Disclosure: I run StarsEarn. Kraken and Bybit buttons on the site are referral links; OKX, Coinbase and MEXC aren't partners of ours.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
