SEO & Public Ticker Pages
How an invisible Blazor WASM platform became crawlable: static SSR prerendering, and a background engine that publishes free stock-grade pages
flowchart TB
A([Admin - Public Ticker Pages screen]):::app
B[POST public-tickers/refresh<br/>Azure Functions API]:::svc
C[(publictickerrefreshjobs<br/>Status - Pending)]:::data
D[SeoPageBuilder<br/>systemd service on report VM]:::svc
E[FMP - 12 datasets per ticker<br/>constituents + fundamentals]:::ext
F[DashboardSummaryBuilder<br/>same grading engine as reports]:::svc
G{Grade changed?}:::svc
H[Claude writes 2-4 sentence<br/>summary - Haiku tier]:::ext
I[Reuse cached summary]:::svc
J[Render static HTML<br/>pages + index + sitemap]:::svc
K[(seo-pages blob container)]:::data
L[Deploy pipeline<br/>merges blob into client-web zip]:::svc
M([app.grademyinvestments.com/grade/TICKER<br/>plain HTML - crawler ready]):::app
N([Google indexes grade pages<br/>visitors funnel to signup]):::sec
A --> B
B --> C
C -->|poll every 60s<br/>+ monthly self-check| D
D --> E
E --> F
F --> G
G -->|yes| H
G -->|no| I
H --> J
I --> J
J --> K
K --> L
D -->|queue via DevOps REST| L
L --> M
M --> N
classDef app fill:#1f2630,stroke:#C0894E,color:#F3EFE8;
classDef svc fill:#241c14,stroke:#E2B583,color:#F3EFE8;
classDef data fill:#22201a,stroke:#C0894E,color:#EBD3AE;
classDef ext fill:#1b1f27,stroke:#5A616B,color:#C4C0B8;
classDef sec fill:#1d2733,stroke:#5cc8e0,color:#eafaff;
The Problem: WASM Is Invisible to Search Engines
Every GMI web property began as standalone Blazor WebAssembly. A crawler fetching any page received an empty HTML shell and a loading spinner — the real content only materialized after the .NET runtime downloaded and booted in the browser. Nearly everything else sat behind Google sign-in. For search engines, the platform effectively did not exist.
The fix came in two waves: first, converting the public-facing pages to prerendered static
SSR so every marketing and documentation page ships as plain HTML; second, adding a set of
free public ticker grade pages (/grade/NVDA and friends) that target
high-intent searches like "NVDA stock grade" and funnel visitors into signup — the grade and
headline stats are free, the full analysis lives behind the app.
Wave One: Static SSR Prerendering
Two Blazor static-SSR projects (Gmi.Technology.Ssr, Gmi.ClientWeb.Ssr) render
the same Razor pages the WASM apps use — but server-side, with zero interactivity. At build time the
pipeline boots each SSR app, crawls every @page route, and writes the responses to
plain index.html files plus a generated sitemap.xml.
- This technology site is fully static — every page you're reading is prerendered HTML; the only JavaScript is Mermaid rendering and nav toggles.
- The client site is a hybrid: the WASM host page was renamed to
app.html, prerendered public pages overlay the deployment, and an IIS rewrite serves static HTML when it exists — falling back toapp.htmlso login and the private app boot WASM exactly as before. - Nothing moved servers: the same Azure App Services serve the same URLs; crawlers just get real content now.
Wave Two: Free Public Ticker Grade Pages
Admins curate which tickers get public pages — individually, by bulk paste, or by syncing whole
index groups: S&P 500, Nasdaq, and Dow Jones membership comes straight from FMP's
constituent endpoints, while Russell 2000 membership derives from IWM ETF holdings. Every enabled
ticker gets a self-contained static page at /grade/{TICKER}: the letter grade (large and
dated), six headline fundamentals, a short plain-English summary written by Claude, internal links to
related tickers, and one signup CTA carrying UTM attribution. A searchable index at
/grade/ lists every graded stock by index.
Same grading engine
Pages reuse DashboardSummaryBuilder — the exact pure-function scoring model the report engine uses. The generator never reimplements grading.
Cost-aware AI
Claude rewrites a ticker's summary only when its grade actually changes; unchanged grades reuse the cached paragraph. A deterministic fallback keeps pages shipping even if the API is down.
Failure isolation
One ticker failing FMP never blocks the run — it keeps serving its previous snapshot and surfaces the failure in the admin screen. The sitemap only ever lists pages that exist.
The Background Process: Gmi.SeoPageBuilder.Console
Page generation runs where the data lives. The App Services deploy from read-only packages
(WEBSITE_RUN_FROM_PACKAGE=1) and the MySQL databases have no public network access, so the
builder runs as a systemd service on the report VM — the same pattern as the report,
billing, and Claude-analysis engines. The flow:
- An admin presses Refresh & Republish (or the built-in monthly check fires) → a job row lands in
publictickerrefreshjobs. - The builder polls the queue, pulls ~12 FMP datasets per enabled ticker (throttled), and computes each grade.
- Grade changed? Claude writes a fresh summary. Otherwise the cached one is reused.
- Every page, the
/grade/index, a search manifest, andsitemap-grades.xmlare rendered deterministically and uploaded to theseo-pagesblob container; stale pages for disabled tickers are deleted. - The builder queues the deploy pipeline via the Azure DevOps REST API — the pipeline merges the blob contents into the client-web package, and the pages go live.
Refresh cadence is deliberately monthly plus on-demand — grades move on fundamentals, not on daily price noise, and a monthly cycle keeps FMP and Claude costs proportionate to an acquisition funnel rather than a product feature.
SEO Plumbing Details
- Crawler-proof by construction — every public page renders fully with JavaScript disabled; ticker pages are under 10 KB of self-contained HTML.
- Canonicals & sitemaps — every page declares its production canonical URL;
robots.txtreferences both the prerendered-site sitemap and the generated grades sitemap, and dev deployments ship aDisallow: /robots so only production gets indexed. - Structured data — ticker pages emit JSON-LD (
WebPage+BreadcrumbList) and OpenGraph/Twitter cards; deliberately no schemas that imply personalized financial advice.
- Freshness signals — the visible "Grade updated" date and each sitemap
<lastmod>come from the actual grade date, so a page never looks stale after an earnings surprise without also being refreshable on demand. - Attribution — the signup CTA carries
utm_source=seo&utm_medium=ticker-page&utm_content={TICKER}, and the pages run the same GA4 property as the app, so conversions attribute back to the specific ticker that earned them. - Compliance — every page carries a not-investment-advice footer and FMP data attribution.