Skip to content

Add an SEO landing page

The prerender subsystem produces 114 static pages from four data modules. Adding a page is a data edit plus a rebuild — but adding a route means keeping three lists in sync.

Architecture: SEO subsystem.

Which kind of page

KindData moduleRoute
Per-chain swap/bridgesrc/data/chainSeo.mjs/swap/:chain, /bridge/:chain
Bridge corridorsrc/data/bridgeRouteSeo.mjs/bridge/{from}-to-{to}
Feature pagesrc/data/featureSeo.mjs/blazai, /rug-check, …
Comparison pagesrc/data/compareSeo.mjs/compare/blazpay-vs-x
Something newA new data module + page componentNeeds the three-list update

The data modules are framework-free ESM on purpose

They are imported by both the React pages and the Node prerender script. One source of truth means the static HTML and the hydrated render are identical — no cloaking risk and no drift. Do not import React, Vite aliases or anything browser-only into a *Seo.mjs file.

Adding a chain page

One edit, in src/data/chainSeo.mjs:

js
export const CHAINS = [

  {
    slug: 'mychain',
    name: 'MyChain',
    chainId: 1234,
    native: { symbol: 'MYC', name: 'MyCoin' },
    category: 'emerging',        // 'emerging' | 'major' | 'non-evm'
    since: '2026',
    explorer: 'https://explorer.mychain.xyz',
    popularTokens: ['MYC', 'USDC', 'USDT'],
    narrative:
      'MyChain is … Liquidity is young and fragmented across new DEXs, so ' +
      'best-price routing matters more here than on established chains.',
    extraKeywords: [
      'mychain dex aggregator', 'swap on mychain', 'mychain defi',
    ],
    extraFaqs: [
      { q: 'What is MyChain?', a: 'MyChain is … It launched in …, uses MYC for gas, and hosts …' },
      { q: '…', a: '…' },
    ],
  },
]

Then:

bash
npm run build     # vite build → prerender-seo.mjs → generate-og.mjs

That emits /swap/mychain/ and /bridge/mychain/, adds them to the sitemap and llms.txt, and generates OG images. No route file changes neededChainLandingPage already handles /swap/:chain and /bridge/:chain.

Write a real narrative

The narrative is the only unique content on the page

It is what makes it rank rather than read as a template. Every existing entry is hand-written — match that. A good one names the chain's actual positioning (Sony-backed, OP Stack, consumer apps) and explains why aggregation matters there specifically.

The extraFaqs should include a "What is {Chain}?" definition FAQ — that is what gets picked up as a featured snippet and cited by AI answers.

Adding a bridge corridor

src/data/bridgeRouteSeo.mjs. The existing set is 4 top sources × 10 emerging destinations + 15 major pairs = 55 pages.

BridgeRoutePage is also the dispatcher for /bridge/:chain

If the route param contains -to- it renders the corridor page; otherwise it delegates to ChainLandingPage in bridge mode. So a corridor slug must contain -to-, and a chain slug must not.

Adding a feature or comparison page

src/data/featureSeo.mjs or compareSeo.mjs. These have top-level paths (/rug-check, not /features/rug-check) with the slug passed as a prop in the route table.

A new top-level path needs the three-list update

See below. The existing five feature paths and four comparison paths are already registered.

Comparison pages are deliberately balanced in tone — the listicle (/compare/best-dex-aggregators) carries ItemList schema. A hostile comparison page ranks worse and reads as marketing; that is a deliberate editorial decision, not timidity.

Adding a genuinely new route

Three lists, all of them:

1. The dispatcher regex

src/index.tsx

ts
const MARKETING_ROUTE =
  /^\/(about|chains|blazai|rug-check|wallet-checker|buy-crypto-inr|perpetuals|
      compare\/[^/]+|swap\/[^/]+|bridge\/[^/]+|my-new-page)\/?$/

2. The light-tree route table

src/MarketingApp.tsx

tsx
<Route path="/my-new-page" element={<MyNewPage />} />

3. The app route table

src/routes/index.tsx — for in-app navigation.

Miss one and it fails in a specific way

MissedSymptom
The regexThe page loads the full 7 MB FullApp bundle instead of the 234 KB marketing tree
MarketingApp.tsx404 inside the light tree
routes/index.tsxBroken in-app navigation

4. Teach the prerender about it

scripts/prerender-seo.mjs needs to know the path and its content source, or the page ships as an empty SPA shell to crawlers.

Rules that will bite you

Web3Modal hooks throw without createWeb3Modal()

MarketingApp deliberately omits the wallet stack — that is the whole point of the split. Marketing pages use MarketingLayout's wallet-free nav, whose catch-all HardRedirect does a full page load into the app.

Trailing slashes

Netlify serves prerendered directories at {path}/ and 301s the slash-less form.

Page typeCanonical
PrerenderedTrailing slash/swap/mychain/
SPA-onlySlash-less/defi/swap

Sitemap <loc> entries must match.

The SPA rewrite must not be forced

force = true breaks all 114 pages

netlify.toml's from = "/*" to = "/index.html" status = 200 must stay non-forced. Non-forced rewrites let Netlify serve existing prerendered files first; forced ones shadow them.

JSON-LD dedupe

Two rules, both fixing Google's "Duplicate field FAQPage" warning:

Never put FAQPage in index.html's global JSON-LD

The shell is the base of every prerendered page and every SPA route, so a global FAQPage duplicates the page-level one everywhere.

Prerender-injected page JSON-LD carries data-seo-prerender="true", and SEOHead removes those scripts on mount when it has its own jsonLd — so a JS-rendering crawler sees exactly one copy. If you add JSON-LD, follow that pattern.

OG images

scripts/generate-og.mjs renders dist/og/{key}.png where the key is ogImageKey(canonical)/swap/hemi/swap-hemi. React pages must pass ogImage={ogImageFor(meta.canonical)}; the prerender's compose() rewrites og:image and twitter:image for the static HTML.

Freshness stamp

Bump SITE.updated when you change copy

It feeds dateModified in the WebPage JSON-LD node. SITE.updated is currently 2026-06-10 and does not update itself.

SiteFooter.tsx renders from FOOTER_COLUMNS / FOOTER_SOCIALS in chainSeo.mjs, and the prerender emits the same footer HTML into every static page so non-JS crawlers get the internal links. Adding a footer link is a chainSeo.mjs edit.

AI_SITE_ORIGIN is exported hardcoded from chainSeo.mjs (because that module is imported by both browser and Node), and the footer points AI tools at it — so all 114 pages cross-link to ai.blazpay.com.

Proof points

Only use the numbers in PROOF_POINTS

js
136,000+  AI chat messages processed
10,000+   AI chat sessions
99.9%+    AI intent accuracy
21        live liquidity providers

These are real, rounded, all-time figures. A request to inflate them 100× was refused. Do not fabricate, and do not round up beyond what the source supports.

Defensible aggregate claims: "30+ chains", "20+ providers", "0.1% flat fee", "self-custody".

Publishing

bash
npm run build            # 114 pages, sitemap, llms.txt, OG images
npm run check:routes     # sanity check
git push origin optimized   # Netlify auto-builds
npm run indexnow         # optional manual submission

Never submit to IndexNow before the key file is deployed

IndexNow caches failed key validations for hours or days and returns 403 until they expire. public/{key}.txt must match KEY in scripts/indexnow-submit.mjs. The key has been rotated once; do not rotate and submit in the same deploy.

Google does not use IndexNow. Submit the sitemap in Search Console instead.

Verification checklist

□ npm run build succeeds
□ dist/{path}/index.html exists and contains real content (not the empty shell)
□ curl the file and grep for your narrative text
□ Exactly one FAQPage per page:  walk dist/, parse all ld+json, count
□ Canonical has a trailing slash and matches the sitemap loc
□ dist/og/{key}.png generated
□ dist/sitemap.xml includes the URL
□ dist/llms.txt includes it
□ The footer renders in the static HTML (view source, no JS)
□ npm run check:routes passes
□ For a new route: all three lists updated
□ SITE.updated bumped if copy changed

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.