Build an evidence-backed comparison site
By Flavio Copes
Turn comparison research into typed data with source IDs, review dates, uncertainty states, and semantic invariants that fail the build.
A comparison site should treat claims like application data.
Do not bury prices, limitations, and sources inside long pages of prose.
Model them as fields. Then reject records that contradict themselves.
Start with the facts you need
A payment-provider profile might contain:
type Provider = {
slug: string
name: string
model: 'merchant-of-record' | 'direct-payments'
legalSeller: 'provider' | 'merchant'
pricing: Pricing
responsibilities: Responsibility[]
sources: Source[]
lastReviewed: string
nextReview: string
}
The important fields are not only the visible ones.
lastReviewed and nextReview turn freshness into data. legalSeller makes a major responsibility explicit. sources makes evidence addressable.
Model uncertainty
Do not force every price into the same numeric shape:
type Pricing =
| {
kind: 'public'
percentage: number
fixed: number
sourceIds: string[]
}
| {
kind: 'negotiated' | 'contradictory'
percentage: null
fixed: null
sourceIds: string[]
}
null means the exact value is not available, not free. The calculator article can own the arithmetic details; the schema only needs to preserve this distinction.
Give sources stable local IDs
Store sources with the record:
{
"id": "pricing",
"url": "https://provider.com/pricing",
"reviewedOn": "2026-07-29"
}
Claims then reference pricing, not a loose footnote number:
{
"kind": "public",
"percentage": 5,
"fixed": 0.5,
"sourceIds": ["pricing"]
}
This lets a validation script detect missing or renamed evidence.
Validate relationships
Schema validation checks shapes. Comparison data also needs semantic checks.
For example:
for (const provider of providers) {
const expectedSeller = provider.model === 'merchant-of-record'
? 'provider'
: 'merchant'
assert(
provider.legalSeller === expectedSeller,
`${provider.slug}: inconsistent legal seller`
)
}
An uncertain price must not expose an exact formula:
if (
provider.pricing.kind === 'negotiated' ||
provider.pricing.kind === 'contradictory'
) {
assert(provider.pricing.percentage === null)
assert(provider.pricing.fixed === null)
}
Each pricing source must exist:
const sourceIds = new Set(
provider.sources.map(source => source.id)
)
for (const sourceId of provider.pricing.sourceIds) {
assert(sourceIds.has(sourceId))
}
These checks protect meaning, not only syntax.
Reject stale data
Inject the review date into the validation script:
const reviewDate = process.env.EDITORIAL_REVIEW_DATE ??
new Date().toISOString().slice(0, 10)
Then enforce:
function isIsoDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const [year, month, day] = value.split('-').map(Number)
const parsed = new Date(Date.UTC(year, month - 1, day))
return parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month - 1 &&
parsed.getUTCDate() === day
}
assert(isIsoDate(reviewDate))
assert(isIsoDate(provider.lastReviewed))
assert(isIsoDate(provider.nextReview))
assert(provider.lastReviewed <= reviewDate)
assert(provider.nextReview >= reviewDate)
assert(provider.nextReview > provider.lastReviewed)
The regular expression enforces the serialized shape. Parsing rejects impossible calendar dates before the ISO strings are compared.
The environment variable makes historical checks and tests deterministic.
Without it, a build from last month can fail differently today.
Generate pages from the records
Once the data passes validation, use it everywhere:
- provider profiles
- pair comparisons
- cost calculators
- country checkers
- recommendation tools
- sitemap entries
Derive values from the reviewed record when possible.
Sometimes an execution-oriented dataset is useful. A calculator may keep compact formula records separate from the richer provider profiles.
When you duplicate a fact this way, fail the build if it diverges:
assert(
calculatorRule.percentage ===
provider.pricing.percentage,
`${provider.slug}: calculator price differs from evidence record`
)
The important part is not pretending duplication never happens. It is making drift impossible to ignore.
Keep research candidates separate
Sometimes a provider looks promising but has not passed review.
Model that state too:
{
"slug": "new-provider",
"status": "research-candidate",
"sources": [
{
"id": "pricing",
"url": "https://new-provider.com/pricing",
"reviewedOn": null
},
{
"id": "legal",
"url": "https://new-provider.com/legal",
"reviewedOn": null
}
]
}
The source shape stays consistent, but null records that the page has not been reviewed yet. Published providers must have a real review date.
Do not let candidates quietly appear in published comparisons.
The absence of a record can mean many things. An explicit candidate status tells future you why it is missing.
The core idea is simple: editorial facts deserve schemas, tests, and failure states too.
Related posts about typescript: