Appearance
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
| Kind | Data module | Route |
|---|---|---|
| Per-chain swap/bridge | src/data/chainSeo.mjs | /swap/:chain, /bridge/:chain |
| Bridge corridor | src/data/bridgeRouteSeo.mjs | /bridge/{from}-to-{to} |
| Feature page | src/data/featureSeo.mjs | /blazai, /rug-check, … |
| Comparison page | src/data/compareSeo.mjs | /compare/blazpay-vs-x |
| Something new | A new data module + page component | Needs 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.mjsThat emits /swap/mychain/ and /bridge/mychain/, adds them to the sitemap and llms.txt, and generates OG images. No route file changes needed — ChainLandingPage 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
| Missed | Symptom |
|---|---|
| The regex | The page loads the full 7 MB FullApp bundle instead of the 234 KB marketing tree |
MarketingApp.tsx | 404 inside the light tree |
routes/index.tsx | Broken 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
MainNav cannot be used
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 type | Canonical |
|---|---|
| Prerendered | Trailing slash — /swap/mychain/ |
| SPA-only | Slash-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.
Footer links
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 providersThese 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 submissionNever 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