We had 52 programmatic SEO pages - one per risk flag our analyzer raises (/signals/honeypot, /signals/rug_pull, /signals/mass_deployer, …) - rendering server-side from our SaaS Express backend. They were great for SEO. They were the wrong stack for the job.
This is a write-up of why we moved them, what broke during the migration, and what we learned about cache TTLs the hard way.
What was wrong with SSR-on-the-SaaS
The 52 pages were dictionary entries. Each one explained what a single risk flag means, why it matters for traders, and how we detect it on-chain. The content was pure prose - nothing on the page changed between page loads. The only dynamic bit was a small “Recent tokens flagged with this signal” block at the bottom, which queried the database for 5 example tokens.
We were rendering all of that - 99% static prose, 1% live query - through Express. The route looked like this:
app.get("/signals/:slug", async (req, res, next) => {
const flag = FLAG_BY_SLUG.get(req.params.slug);
if (!flag) return next();
const samples = await getTokensByFlag(flag.slug, 5, 40);
res.send(renderSignalPage(flag, samples, lang));
});
Three things made this stack feel wrong:
- The pages lived on
app.rektradar.io. Visitors browsing our marketing site (rektradar.io/blog,/about,/legal) would click “Signals” in the nav and traverse onto a different domain, with a different header, different fonts in subtle places, different toggle styles. Same brand, two products, every click reminded you. - TTFB was bottlenecked by the database. Even with a 5-row LIMIT and a 40ms timeout on the upstream call, the server had to do a real database round-trip for content that hadn’t changed in months. Cache helped, but we were paying real CPU on every cold cache.
- The marketing site had a perfectly good Astro pipeline already.
/blog,/about,/legalall render at build time, sit on Cloudflare Pages, and serve in 30ms TTFB worldwide. Adding 52 more pages to that stack costs us nothing at runtime.
So: move the 52 pages off the SaaS, onto the marketing site, as Astro static. Keep the “recent tokens flagged” block but fetch it client-side from the existing API endpoint.
The plan
BEFORE AFTER
---- ----
app.rektradar.io/signals → rektradar.io/signals/
app.rektradar.io/signals/honeypot → rektradar.io/signals/honeypot/
... ...
(SSR, 100ms TTFB, DB-coupled) (Static, 30ms TTFB, CDN-cached)
Three pieces of work:
1. The Astro pages. Copy the 52-flag dictionary (risk-flags.ts, ~600 lines) from the SaaS repo into the marketing site. Use getStaticPaths() to generate one HTML file per flag at build time. Inline the same JSON-LD (TechArticle per slug, ItemList on the index). Same severity pills, same related-signals section, same canonical URL - just rendered elsewhere.
export function getStaticPaths() {
return FLAGS.map((flag) => ({
params: { slug: flag.slug },
props: { flag },
}));
}
2. The 301 redirect on the SaaS. Replace the old Express route with a redirect that preserves the query string (so ?lang=fr survives the hop):
app.get("/signals/:slug", (req, res, next) => {
const slug = req.params.slug.toLowerCase();
if (!FLAG_BY_SLUG.has(slug)) return next();
const qs = req.url.indexOf("?") !== -1 ? req.url.slice(req.url.indexOf("?")) : "";
res.redirect(301, `https://rektradar.io/signals/${slug}/${qs}`);
});
3. The CORS-allowed tokens endpoint. The “recent tokens flagged” block is the one piece of dynamic content. The Astro page fetches it client-side from app.rektradar.io/api/signals/:slug/tokens. The new endpoint returns { tokens, total }, locked to two origins:
const ALLOWED_ORIGINS = new Set([
"https://rektradar.io",
"https://www.rektradar.io",
]);
function applyCors(req, res) {
const origin = req.headers.origin;
if (typeof origin === "string" && ALLOWED_ORIGINS.has(origin)) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Vary", "Origin");
}
}
No wildcard. No proxy abuse. The page degrades gracefully when the fetch fails - a localized “couldn’t load recent tokens” message instead of a broken layout.
What broke (twice)
The migration shipped in three PRs. Two of them were bugs caught only by a real human looking at the live site.
Bug 1 - <a> is not role="button"
The /pricing page in the SaaS frontend got a CSS refactor in the same week, switching the subscribe button from a Vuetify <v-btn> to a styled <a href="#">. Visually identical - the active hover, the accent color, everything. Functionally identical from the user’s perspective.
But the Stripe E2E test was using:
page.getByRole("button", { name: text.subscribe }).first().click()
<a> resolves as role="link". The click silently missed. Playwright never failed loudly - it just walked off and called the next assertion, which then timed out 30s later on page.waitForURL(/checkout\.stripe\.com/) because no Stripe redirect ever fired. We saw 4 retries, all 30s timeouts, every test failing for a reason the logs blamed on the wrong layer.
Fix: <a href="#"> → <button type="button" class="cta-link" :aria-label="…">. Same visual, restored semantics, E2E passed on the next run.
The lesson is the obvious one but worth restating: visual parity between two HTML elements does not imply semantic parity. The tests treat the DOM as a tree of roles, not a render tree.
Bug 2 - The toggle that flipped without flipping
The Astro pages render in two languages. We render both <section class="lang-en"> and <section class="lang-fr" hidden> for every content block - H1, summary, body sections, related signals, CTAs. A small inline script in Nav.astro swaps which is hidden when the visitor clicks [FR].
After the migration, clicking FR turned the pill red but the body stayed in English.
browser_evaluate in Playwright was clear:
ens_count: 13 ens_hidden: 0
frs_count: 13 frs_hidden: 13
fr_active: true
html_lang: 'fr'
The pill flipped, the <html lang> updated, localStorage.rr_lang = "fr" got persisted - but every body block stayed visible in English.
The cause was an old race condition. Nav.astro is rendered above the <slot/> in Layout.astro. The inline script runs synchronously the moment the parser reaches it. At that point, the <main> underneath has not been parsed yet, so document.querySelectorAll('.lang-en') returns an empty NodeList. The body-swap branch was guarded by if (ens.length && frs.length) - empty list, branch skipped, only the pill state survived.
It happened to work on /about and /legal because each ships only one .lang-en and one .lang-fr, and the parser usually reached them before Nav’s script for those simpler page shapes. The new /signals/<slug> page has 13 bilingual blocks, which made the race visible.
Fix: wrap init() in a DOMContentLoaded guard so the script waits for the body to exist before querying it.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
Same lesson as bug 1, different layer: code that “works” on simpler pages can hide a real timing bug that the next more-complex page exposes.
The cache won 24 hours
This is the most “trust your stack” part of the migration.
Once the new pages were live and the 301 was deployed, we expected app.rektradar.io/signals/honeypot to return a redirect immediately. It did - when we hit it with ?_cb=$(date +%s). Without the cache-buster, it kept returning a 200 with the old SSR HTML. For 24 hours.
Our gateway nginx had cached the SSR responses with s-maxage=86400. The Express layer had been replaced with a 301 - but nginx was happily serving its cached 200 to any request that matched a cache key it had seen before, which means anyone who visited /signals/<anything> in the previous 24 hours got served stale content with stale canonicals (<link rel="canonical" href="https://app.rektradar.io/…">) that contradicted the new canonicals on rektradar.io.
If you only check the new URL, everything looks fine. If you check the old URL the way Google does - without a cache-buster, with the cache key it generated when it last crawled - you see the old page.
We patched it with three signals to Google:
app.rektradar.io/sitemap.xmlno longer advertises the signals sub-sitemap.GET /sitemap-signals.xmlnow returns410 Gonewith a plain-text pointer to the new URL. 410 is “permanently removed” - Google drops it from the index faster than waiting for the 24h cache to expire on its own.robots.txtflipsAllow: /signals/→Disallow: /signals/. Stops the next crawl from being wasted on URLs whose 301 won’t even be hit, because nginx is serving cached 200s.
The migration window - the period where Google sees a mix of old SSR canonicals and new Astro canonicals - narrows from 24 hours to whatever the next nginx cache miss is.
Net result
- TTFB on
/signals/<slug>: ~100ms (SSR) → ~30ms (CDN-cached static). - One brand surface across
/blog,/about,/legal,/signals- same nav, same logo blink, same EN/FR toggle, same footer, no domain hop on click. - The “recent tokens flagged” block is still live, just rendered after page load instead of blocking the response.
- Zero breakage on the SaaS itself -
/api/signalsand/api/signals/:slugkeep their old shapes, plus a new/api/signals/:slug/tokensfor the fetch. - Three small bugs caught by the migration, each pointing at the same lesson: things that “work” on simpler pages can hide a real bug the next page exposes.
Migrations are mostly an audit of what your existing system was actually doing, not an addition of new behavior. Ours surfaced a CSS specificity issue on /about from a previous PR that had been masked by the wrong padding-top, an E2E selector that depended on Vuetify’s specific button rendering, and a 24-hour nginx cache horizon nobody had tested against.
If you have programmatic SEO pages running through your application server, look at what they actually do at request time. If the answer is “render mostly-static content and one cheap query,” they probably belong on a static site.