On assetvs.com we run a programmatic SEO site comparing investment returns across 16 countries and 5 asset classes (stocks, bonds, real estate, bills, gold). The site has 3,655 URLs in the sitemap. Last week we discovered that 2,232 of them — every currency-pair page — were silently 404ing.
This is the story of how one missing table turned a third of our site dark, and how a single World Bank endpoint brought it all back online in an afternoon.
The architecture (quick recap)
assetvs uses the same virtual-routing pattern we've written about before on the network: no wp_posts, just custom tables and rewrite rules. The relevant tables are:
| Table | Rows | Purpose |
|---|---|---|
wp_ir_countries |
16 | G7 + EU + ANZ + JP, ISO codes |
wp_ir_assets |
5 | stocks/bonds/real-estate/bills/gold |
wp_ir_annual_returns |
3,955 | nominal returns, country × asset × year |
wp_ir_inflation |
976 | for real-return calculation |
wp_ir_comparisons |
100 | full-period asset-vs-asset summaries |
wp_ir_exchange_rates |
0 → 976 | currency pair history |
That last row is the punchline.
The URL patterns
The router (IR_Router) maps slugs onto eight page types:
-
/{country}/and/{asset}/— 21 hubs -
/{country}/{asset}/— 80 country-asset combos -
/{country}/{asset_a}-vs-{asset_b}/— 100 comparisons (alphabetical canonical) -
/{country}/{asset_a}-vs-{asset_b}/{decade}/— sliced by 1960s–2010s -
/{asset}/{country_a}-vs-{country_b}/— 600 cross-country comparisons -
/currencies/{a}-{b}/and/currencies/{a}-{b}/{year}/— 2,232 currency pages -
/rankings/{asset}/,/about/,/data/, etc.
Everything renders server-side from the custom tables. No posts, no taxonomies, no theme fight.
The bug, hiding in plain sight
The currency builder (build_currency_pair) had this shape:
$history = $this->get_fx_pair_history($a, $b);
if (empty($history)) {
return null;
}
// ...render the page
Returning null from the page builder cascades up to a 404. Reasonable defense — but wp_ir_exchange_rates had never been populated. We'd built the schema, written the renderer, generated 2,232 rewrite rules, listed the URLs in the sitemap, and submitted them to Google Search Console for two months. All of them 404.
GSC's coverage report had been flagging "Not found (404)" for weeks, but with 3,655 URLs and zero indexed pages overall (new domain trust issues), nobody had drilled into which URLs were 404 vs which were just unindexed-but-live.
The fix: one API
The World Bank publishes annual official exchange rates as indicator PA.NUS.FCRF, free, no auth:
https://api.worldbank.org/v2/country/{ISO}/indicator/PA.NUS.FCRF?date=1960:2020&format=json
For each of our 16 countries, that's one HTTP call returning 61 annual rates against USD. Sixteen calls total. We wrote a one-shot fill script:
foreach ($countries as $c) {
$rows = wb_fetch($c->iso2);
foreach ($rows as $year => $rate) {
$wpdb->insert('wp_ir_exchange_rates', [
'country_id' => $c->id,
'year' => $year,
'rate_per_usd' => $rate,
'yoy_change' => $yoy,
]);
}
}
Result: 976 rows. The currency pair builder derives any (A, B) pair by triangulating through USD — so 16 base currencies give us C(16,2) × 2 directions × 61 years × a few view modes = the 2,232 URLs we'd been promising Google for two months.
Lessons we keep relearning
1. "Sitemap submitted" ≠ "page renders." GSC will happily accept a sitemap of 404s and quietly file them under "Discovered – currently not indexed" or "Not found." There is no alarm. Build a synthetic monitor that hits one URL per page-type weekly and asserts HTTP 200. We'd have caught this in March.
2. Returning null from a builder is a foot-gun. The render path collapses three failure modes into one 404: missing data, missing slug, and genuinely-unknown URL. Now we log a distinct warning when a known-routed slug returns null, so missing tables surface in error logs instead of just the GSC dashboard.
3. New-domain GSC trust dwarfs technical issues. Even with the FX table filled, indexing didn't snap back overnight. We're at zero indexed pages two months in, and lighting up the currency tier is a prerequisite for indexing — but not sufficient. Content depth is next: stock index names per country, country-level macro from World Bank, narrative blocks generated through our LLM pool.
4. Cross-site editorial linking is cheap and free crawl signal. We added a historysaid_for_country($slug) linker that pulls the top 5 inquiries citing the country slug from historysaid.com/wp-json/hs/v1/inquiries/by-country/{slug} (12-hour transient cache) and renders them as a "Further Reading" block. United States gets 8 inquiries, Germany 10, UK 10. Two sites, one network, free internal-link equity.
What's next
- Fill
stock_index_nameper country (S&P 500, FTSE 100, DAX, Nikkei…) so each/{country}/stocks/page has a named anchor instead of generic "stocks." - Pull World Bank macro (GDP per capita, inflation regime, currency volatility) into country hubs.
- Generate narrative paragraphs (~150 words) per country-asset page through our LLM pool — same enrichment pattern we use on HistorySaid's 18,304 indicator pages.
The larger lesson: when 2,232 URLs go dark, it usually isn't an SEO problem. It's a missing INSERT.
Live site: assetvs.com
Top comments (0)