Canada Mortgage Compare
Project Case Study
Canada Mortgage Compare
An independent reference site comparing advertised mortgage rates from five major Canadian lenders, refreshed twice daily. A pure static Next.js export with no database and no backend: rates are collected by TypeScript scrapers in GitHub Actions and committed as JSON.
View Live Project →Overview
Canada Mortgage Compare aggregates advertised mortgage rates from major Canadian lenders and presents them side by side. The hero states the scope without embellishment: "Compare advertised Canadian mortgage rates."
It currently tracks 64 products across five lenders — CIBC, National Bank of Canada, RBC, Scotiabank, and TD — refreshed twice daily.
The defining constraint is what the project refuses to be. There is no database, no backend, and no paid service. The entire site is a static Next.js export served from Cloudflare Pages, and the rate data lives as committed JSON in the repository.
The Honesty Problem
Rate comparison sites have a structural credibility problem: they are usually lead-generation funnels dressed as reference material. This project takes the opposite position, and enforces it in code.
Advertised rates are not offers. Your actual rate depends on credit, income, down payment, and the property. The site says so rather than implying otherwise.
No `Offer`/`Product` structured data. SEO markup is limited to Organization, WebSite, and BreadcrumbList. Emitting Offer markup for a rate that isn't guaranteed would, as the code puts it, be a lie in a machine-readable format — worse than one in prose.
BMO is registered but disabled, because it needs permission before it can be enabled. No BMO rates are published.
Architecture
No database, on purpose
Rates change twice a day and the dataset is small. A database would add an operational surface with nothing to show for it, so the pipeline is: collectors run in CI → validate → commit JSON → the commit triggers a Cloudflare Pages deploy.
next.config.ts sets output: "export" with trailingSlash: true. Static export forbids SSR, ISR, route handlers, and server actions — so the build fails loudly if anyone adds one. The constraint enforces itself.
Collectors
Custom TypeScript collectors live in collectors/, one per lender, registered in a central registry. Cheerio parses the HTML; Playwright is available but installs conditionally, and currently never runs, because every enabled collector works over plain fetch.
Guardrails protect against the failure mode that matters — a lender redesigns their page and a selector latches onto the wrong cell:
Rates must fall within a 0.5–15% band
A jump larger than 1.5 percentage points is treated as an anomaly
30-second timeout, 1.5-second polite delay, one anomaly retry
Scheduling that respects timezones
The GitHub Actions cron is 0 12,21 * * * — twice daily, but deliberately 9 and 15 hours apart rather than evenly split. 12:00 UTC is 08:00 in Toronto, catching overnight moves at open; 21:00 UTC is 17:00 Toronto, after close and 14:00 in Vancouver. An earlier 0 6,18 schedule was rejected because 06:00 UTC is 02:00 in Toronto — a wasted run.
The pipeline commits only if the generated data actually changed, since every commit costs a deploy.
Technical Implementation
Canadian mortgage math
This is the part most tools get wrong. Canadian fixed mortgages compound semi-annually under the Interest Act, not monthly as in the US. The periodic rate is derived accordingly:
i = (1 + j/2)^(2/n) − 1decimal.js carries the arithmetic to avoid floating-point drift. Six payment frequencies are supported, and accelerated bi-weekly and weekly derive from the monthly payment divided by 2 or 4 — modelling what lenders actually do, rather than solving for an exact amortization that no lender offers. Payoff inside the term is handled so total cost isn't overstated. The one documented approximation: variable rates receive the same semi-annual treatment.
History as a public record
Rate history is committed JSON, capped at 2,000 entries, tracking product_added, rate_changed, terms_changed, and product_removed. Two integrity rules stand out:
A failed or disabled collector emits no entries at all. Inferring product_removed from a parser failure would put a lie in the public record.
New products are dated to the lender's own effective date, not the collection run, so a first run doesn't read as "everything changed at once."
A filter that hides itself
Every current product is nationally available. Rather than show a province filter that implies a distinction the data can't support, the filter hides itself — and returns automatically the moment a province-limited lender is added.
No chart library
Rate history renders as hand-written SVG. As the component notes, a single line chart does not justify shipping a chart bundle.
Tested against real fixtures
Eleven Vitest suites run against captured HTML fixtures for each lender, so a lender's redesign surfaces as a test failure rather than as bad data on the live site.
What This Case Study Demonstrates
Choosing the smallest architecture that fits the problem, and letting the build enforce it
Treating data integrity as an editorial responsibility, not just a correctness concern
Getting domain math right where the obvious implementation is wrong
Building a resilient scraping pipeline with guardrails, fixtures, and honest failure behaviour
Running a real data product at effectively zero infrastructure cost
Live: https://canadamortgage.servith.com/
Role: Solo delivery (collectors → mortgage math → integrity rules → UI → CI)