Appearance
SEO / AEO subsystem
defi-dex is a client-rendered Vite SPA with no SSR, so Helmet meta is invisible to non-JS crawlers — GPTBot, Google-Extended, PerplexityBot, ClaudeBot. Rather than migrate to SSR, a build-time prerender subsystem was added in June 2026.
It produces 114 static pages, a 120-URL sitemap, llms.txt, llms-full.txt and a per-page OG image.
How it works
Framework-free ESM is the whole trick
src/data/chainSeo.mjs is imported by both the React pages and the Node prerender script. One source of truth means the static HTML and the hydrated React render identical content — no cloaking risk, and no drift.
To add or edit a chain page, edit only that file.
Data modules
| Module | Drives |
|---|---|
src/data/chainSeo.mjs | Per-chain pages, SITE, PROOF_POINTS, FOOTER_COLUMNS, FOOTER_SOCIALS, ABOUT, JSON-LD builders |
src/data/bridgeRouteSeo.mjs | /bridge/{from}-to-{to} corridor pages |
src/data/featureSeo.mjs | /blazai, /rug-check, /wallet-checker, /buy-crypto-inr, /perpetuals |
src/data/compareSeo.mjs | /compare/blazpay-vs-{uniswap,1inch,jupiter}, /compare/best-dex-aggregators |
Per-chain fields
Each chain carries slug, chainId, native, category, since, explorer, popularTokens, a hand-written narrative, extraKeywords and extraFaqs (including a "What is {Chain}?" definition FAQ).
Categories drive grouping on the hub. 24 chains are covered, from emerging (soneium, hemi, berachain, sei, sonic, unichain, apechain, abstract, worldchain, hyperevm, opbnb, ronin) through majors to solana and tron.
Page components
| Component | Routes |
|---|---|
pages/seo/ChainLandingPage.tsx | /swap/:chain, /bridge/:chain (mode prop) |
pages/seo/ChainsHub.tsx | /chains |
pages/seo/BridgeRoutePage.tsx | /bridge/{from}-to-{to} — and the dispatcher for /bridge/:chain |
pages/seo/FeatureLandingPage.tsx | The five feature paths (slug passed as a prop) |
pages/seo/ComparePage.tsx | The four comparison paths |
pages/defiLandingPage/index.tsx | /about |
BridgeRoutePage checks whether the param contains -to-: if so it renders the corridor page, otherwise it delegates to ChainLandingPage in bridge mode.
The build chain
npm run build
= vite build
→ node scripts/prerender-seo.mjs # 114 pages + sitemap + llms.txt
→ node scripts/generate-og.mjs # dist/og/*.pngprerender-seo.mjs composes dist/index.html as a shell and injects static HTML from the data modules. It also emits the same SiteFooter HTML into every page, so non-JS crawlers get the internal links too.
generate-og.mjs renders 1200×630 PNGs via @resvg/resvg-js with vendored Inter TTFs in scripts/assets/fonts. The key convention is ogImageKey(canonical) — /swap/hemi/ → swap-hemi. The prerender's compose() rewrites og:image / twitter:image (and their alts); React pages pass ogImage={ogImageFor(meta.canonical)}.
Page inventory
| Group | Count |
|---|---|
| Chain pages | 48 (24 chains × swap/bridge) |
| Bridge corridors | 55 (4 sources × 10 emerging destinations + 15 major pairs) |
| Feature pages | 5 |
| Comparison pages | 4 |
| Hub + about | 2 |
| Total | 114, sitemap 120 URLs |
Deep-link preselect
Landing-page CTAs link with ?chain={slug}:
/defi/swap?chain=hemisetsfromTokento that chain's native asset./defi/bridge?chain=seisetstoToken.
Logic lives in src/utils/chainPreselect.ts — resolveAppChain maps a slug to the backend chain by chainId or name, and buildNativeToken synthesises the token record. defaultChainId is passed to TokenModal, which syncs it via effect.
Solana and TRON resolve by name, because the app uses internal ids (102, 728126428).
Schema / JSON-LD
Two hard-won dedupe 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.
Prerendered JSON-LD is marked and removed on hydration
Prerender-injected page JSON-LD carries data-seo-prerender="true". SEOHead removes those scripts on mount — but only when it has its own jsonLd — so a JS-rendering crawler sees exactly one copy.
pageJsonLd includes a WebPage node with dateModified from SITE.updated. index.html carries a WebSite node, full sameAs socials and a featureList. SEOHead gained jsonLd and noindex props.
Canonical and trailing slash
Netlify serves prerendered directories at {path}/ and 301s the slash-less form. Therefore:
| Page type | Canonical form |
|---|---|
Prerendered (/swap/hemi/, /chains/, /about/) | Trailing slash |
SPA-only (/defi/swap) | Slash-less |
The SPA rewrite must not be forced
netlify.toml's from = "/*" to = "/index.html" status = 200 must stay non-forced. Non-forced rewrites let Netlify serve the existing prerendered files first; force = true shadows them and silently breaks all 114 pages.
Crawler access
public/robots.txt explicitly allows AI crawlers. llms.txt and llms-full.txt are regenerated on every build.
IndexNow
| Item | Value |
|---|---|
| Key | fbaf800f3098a10a73e20691138e2590 |
| Key file | public/{key}.txt — must match KEY in scripts/indexnow-submit.mjs |
| Automation | plugins/netlify-plugin-indexnow, onSuccess, production context only |
| Manual | npm run indexnow |
| Reaches | Bing and Yandex → Copilot, ChatGPT search |
Never submit before the key file is deployed
IndexNow caches failed key validations for hours or days and returns 403 until they expire. The key has been rotated once already; do not rotate and submit in the same deploy.
Google does not use IndexNow. Use a Search Console sitemap submission instead.
Analytics
src/utils/analytics.ts, called from src/index.tsx. A no-op unless VITE_GA_MEASUREMENT_ID is set as a Netlify env var.
It detects AI referrers (chatgpt, perplexity, gemini, copilot, claude, grok, meta-ai, …) and fires an ai_referral event once per session plus an ai_referrer user property. That is how AEO/GEO performance is measured at all.
Proof points
PROOF_POINTS in chainSeo.mjs holds the only numbers allowed in marketing copy:
| Stat | Value |
|---|---|
| AI chat messages processed | 136,000+ |
| AI chat sessions | 10,000+ |
| AI intent accuracy | 99.9%+ |
| Live liquidity providers | 21 |
These are real, rounded, all-time figures — and were kept honest deliberately
A request to inflate them 100× was refused. Do not fabricate. SITE.updated (currently 2026-06-10) is the freshness stamp — bump it when copy changes, because dateModified in the JSON-LD reads from it.
Site footer
src/component/SiteFooter.tsx is mounted in MainLayout, so it renders on every app page. The link model is FOOTER_COLUMNS / FOOTER_SOCIALS in chainSeo.mjs, and the prerender script emits the same footer HTML into every static page so non-JS crawlers get the links.
chainSeo.mjs also exports AI_SITE_ORIGIN — hardcoded, because that module is imported by both the browser and Node — and the footer points AI tools at it, so all 114 prerendered pages cross-link to ai.blazpay.com.
Route checking
npm run check:routes is phase-aware. Share artifacts (/report/:id, /certificate/:id, /token/:address, /wallet/:wallet) must keep working on defi until ai.blazpay.com resolves.
Pass -- --cutover only at step 5 of the runbook in AI_SITE_SETUP.md.
Adding a page
Checklist: add an SEO landing page.
Known limits
- No server-side OG rendering. Social bots see the global OG fallback on
/report/:id,/certificate/:idand/wallet/:wallet. Googlebot is fine becauseSEOHeadruns. /and/defi/*are not prerendered. They serve the baseindex.htmlonly. Prerendering the home page is a listed remaining idea.- The 301 map for the AI split is staged commented-out in
netlify.toml. Enabling it early 301s live public share links into a dead domain. X-Frame-Options: SAMEORIGINon/*with no override blocks the rug-check embed badge from third-party iframes on defi.netlify.ai.tomlfixes it for/token/*/embed.- Content freshness is manual.
SITE.updateddoes not update itself.