How fido-landing-pages works — Fido
Internals · landing.fido.money/how-it-works

How fido-landing-pages works

A guided tour of the stack, the auth gate, the deploy flow, and the why-we-built-it-this-way decisions.

Frontend
Astro 5 + shadcn/ui
Hosting
AWS Amplify (eu-west-1)
Auth
Auth0 → Google (PKCE)
DNS
Cloudflare → Amplify CF

1. The repo layout

One repo, one Astro app, raw HTML pages dropped under pages/.

fido-landing-pages/
├── pages/                     # your content lives here
│   ├── _template/index.html   # copy this to start
│   ├── pilot/index.html
│   └── how-it-works/index.html
├── src/                       # Astro shell — don't touch
│   ├── pages/
│   │   ├── index.astro        # the / route
│   │   └── [slug].astro       # catch-all → reads pages/{slug}/index.html
│   ├── layouts/BaseLayout.astro  # auth wrapper
│   └── components/ui/         # shadcn primitives
├── amplify.yml                # build spec
└── astro.config.mjs

2. How a page becomes a route

Astro's catch-all route src/pages/[slug].astro uses Vite's import.meta.glob('../../pages/*/index.html', { query: '?raw' }) to slurp every pages/<name>/index.html at build time. For each directory it emits dist/<name>/index.html wrapped inside BaseLayout. Directories starting with _ are skipped (that's how _template stays out of the routes).

3. The auth gate

BaseLayout.astro sets <html style="visibility: hidden"> as the very first thing. The body is invisible until JS resolves auth, so nothing flashes before the redirect. Then a small inline script does:

// 1. Auth0 redirected back with an error → render denial, stop
if (params.has('error')) showDenied(params.get('error_description'));

// 2. OAuth callback → exchange code for tokens, strip the query
if (params.has('code')) await auth0.handleRedirectCallback();

// 3. Not authed → bounce to Auth0 (no body paint, no flap)
if (!await auth0.isAuthenticated()) await auth0.loginWithRedirect();

// 4. Authed → reveal
document.documentElement.style.visibility = 'visible';

The Auth0 Action verify-fido-domain calls api.access.deny('invalid_email_domain') for any email whose domain isn't fidocredit.com. Auth0 then redirects back with ?error=access_denied, which the gate above catches without looping.

4. The deploy flow

$ git push        # to master, after PR reviewGitHub webhook → AWS Amplify$ npm ci
$ npm run build  # Astro emits dist/ with one HTML per pageaws s3 sync + CloudFront invalidate (Amplify-managed)✓ live at landing.fido.money/<your-page>  (~3 min)

5. Add your own page

$ cp -r pages/_template pages/my-team-handbook
$ $EDITOR pages/my-team-handbook/index.html
$ git checkout -b add-page-my-team-handbook
$ git add pages/my-team-handbook
$ git commit -m "add my-team-handbook page"
$ git push -u origin add-page-my-team-handbook
$ gh pr create --base master

After merge, your page is live at landing.fido.money/my-team-handbook within ~3 minutes. No second PR, no Crossplane, no DNS work.

6. Trade-offs we accepted

ConcernToday
Auth enforcement Client-side only. A direct curl of an asset URL returns the HTML. Don't put data on a page that mustn't leak via direct URL.
Per-page DNS None. Single subdomain landing.fido.money, sub-paths per page.
Build cost Every page rebuilds on every push (Astro static build). Sub-second per page; not a concern at scale.
Route discovery The / route does not list pages — by design, route names stay private to people who have the link.

7. Why this stack (and not the previous attempt)

v1 of OPS-1201 tried to do per-page provisioning via Crossplane on Cloudflare Pages: one Pages project per page, one DNS record per page, a shared Cloudflare Worker for auth, a Crossplane Composition to wire it together. The wildbitca upjet provider turned out to be amd64-only on our 100% Graviton cluster, and the path of least resistance kept getting longer. We reverted the whole stack and switched to the approach this page describes: stop per-page provisioning entirely. Static-site routing is solved by every modern build tool — we don't need Crossplane to do it.

Net result: 3 repos became 1, 2 PRs became 1, ~1000 lines of infra became ~80, and the time-to-ship a page went from "days, abandoned" to "~3 minutes."

8. The bits you might want to look at