Capstone: Integrating the Predictor Into the Existing Astro Site
Premier League Predictor: FastAPI & PostgreSQL
Chapter 12 (Capstone) · Integrating the Predictor Into the Existing Astro Site
Eleven chapters have built a genuinely working predictor — a real normalized schema, admin tooling, a fast fixture-entry UI, four scored prediction sources, two real league tables, promotion/relegation, a working selector, and a real deployment plan. This closing chapter covers the actual point of building it: getting it in front of the user, mounted onto their real, existing Astro-based site, then reviews the whole course chapter by chapter.
Three Real Ways to Mount a FastAPI App Onto an Astro Site
Astro's own live site is a static build served by a web server — it has no Python runtime of its own, and doesn't need one for its own pages. Integrating this project is a matter of getting both apps served correctly, side by side, behind the same web server:
| Option | What It Looks Like | Real Effort |
|---|---|---|
| Subdomain | predictor.example.com, a fully separate vhost |
Lowest — no code changes at all, just DNS + a new nginx server block |
| Path-mounted reverse proxy | example.com/predictor/, same domain as the Astro site |
Low — one nginx location block plus a real fix to this course's own JS fetch calls |
| JSON API + Astro frontend | FastAPI returns data only; an Astro page/island renders it | Highest — every page's own vanilla-JS logic effectively rebuilt in Astro/React |
The Realistic Choice: Path-Mounted Reverse Proxy
A subdomain works but feels like more infrastructure than a personal project needs; a full JSON-API rewrite defeats much of the point of having already built a working vanilla-JS frontend across ten chapters. A path-mounted reverse proxy is the real middle ground:
proxy_pass http://127.0.0.1:8000/; — the trailing slash matters — strips /predictor/ before forwarding, so FastAPI itself sees plain /api/seasons requests exactly as every earlier chapter tested them, with no server-side routing changes needed at all.
fetch('/api/...') — an absolute path. A browser resolves an absolute path from the site's own root, not from the current page's own folder. Loaded at https://example.com/predictor/index.html, a call to fetch('/api/seasons') resolves to https://example.com/api/seasons — a path nginx has no location block for at all, since only /predictor/ and / exist. It falls through to the Astro site's own catch-all and returns Astro's 404 page instead of real JSON. This never shows up on localhost:8000, where the app already sits at the root — it only appears the moment the app is genuinely mounted at a sub-path, which is exactly why it's easy to miss until a real deployment surfaces it.
The fix, and it's a genuine one-time change: every fetch() call across selector.js, fixtures.js, predictions.js, both table scripts, and rollover.js drops its leading slash — a relative path instead of an absolute one:
A relative path resolves against the document's own base URL — and rather than depending on which folder each individual page happens to sit in, one shared <base> tag sets that base URL explicitly, for every page, in one place:
header('Location: ...') redirects breaking once mounted at a sub-path; Personal Catalogue (Django & PostgreSQL)'s own capstone found server-generated {% url %} links needing Django's real FORCE_SCRIPT_NAME setting. This course's own version of the identical underlying problem — "the app doesn't know it's not at the web root" — shows up in neither of those places, since nothing here redirects server-side or renders a template; it shows up in static JavaScript's own absolute fetch() paths instead, fixed not by a single backend setting but by a relative-path convention plus one shared <base> tag. Three real frameworks, three genuinely different failure points, one recurring lesson.
localhost:8000, the app already sits at the root — a page loaded from http://localhost:8000/index.html resolves fetch('api/seasons') to http://localhost:8000/api/seasons correctly with no <base> tag present at all. The tag only needs to exist (or be templated in) for the deployed, sub-path-mounted version — every relative fetch call itself never needs to change between the two environments.
Making It Feel Like Part of the Same Site
A reverse proxy solves reachability, not visual consistency. Chapter 10's own style.css already gives the predictor a real dark theme matching this course's own documentation — matching its accent colors to whatever the Astro site's own build actually uses is a small, one-time styling pass, and the natural place for a real "← Back to Main Site" link.
A Real Alternative for Deeper Integration: A JSON Endpoint
If, later, the current gameweek's own real league position or predictor standings need to appear directly on an Astro page rather than linking out, the reverse-proxy setup already built supports that too — FastAPI already returns JSON from every route in this course, so an Astro island could fetch() the exact same /api/seasons/{id}/table route this course already built, with no new backend code required at all. This isn't built out further here — it's flagged honestly as the real next step if the project's own scope ever grows toward a tighter Astro-embedded experience.
Capstone: What Each Chapter Actually Built
| Chapter | What It Contributed |
|---|---|
| 1 | The real project spec (4 real prediction sources, two league tables, promotion/relegation) and a working FastAPI + SQLAlchemy + PostgreSQL connection |
| 2 | The real Team/Season/SeasonTeam/Gameweek/Fixture schema, the dual self-referencing foreign key fix on Fixture, and nullable scores as "not yet played" |
| 3 | Season/team admin routes, the reuse-vs-create Team lookup keeping a relegated-then-promoted club as one permanent row, and the 20-team cap |
| 4 | The click-to-pair fixture-entry UI, and the real server-side duplicate-team guard client-side disabling only assists |
| 5 | The Prediction table, a real PostgreSQL partial unique index allowing multiple guests but one user/expert/AI prediction, and the upsert pattern |
| 6 | The real, confirmed point values (40/10), score_prediction(), and the guest-averaging problem resolved by averaging points, not scorelines |
| 7 | The real league table — a UNION ALL normalizing home/away rows, LEFT JOIN from SeasonTeam so an unplayed team still gets a real row |
| 8 | The prediction league table — a two-pass CTE averaging guest points per fixture before summing across the season |
| 9 | Promotion/relegation — reusing Chapter 7's own table as the real source of truth, a relegation preview, and an honest note that "removing" a team deletes nothing |
| 10 | The gameweek/season selector, and a genuine fix to Chapter 4's own flagged usedTeamIds gap |
| 11 | Deployment — gunicorn workers, a real connection-pool sizing calculation, and the /docs production caveat |
| 12 | Mounting the finished predictor behind the same domain as the real, existing Astro site |
What's Still Genuinely Open
project_issues.md's own P3 entry named several real questions this course deliberately didn't resolve — not oversights, but genuine product decisions left for whenever they're actually needed: whether past seasons get backfilled retroactively, whether a real fixture ever needs to move between gameweeks after being entered, and whether this stays a genuinely single-user tool or eventually needs real multi-user support (a question Chapter 3's own honest "no access control" warning already anticipated). None of these blocked building a genuinely working predictor — they're simply the honest list of what comes next, if it ever needs to.
Hands-On Exercises
Write the nginx location /predictor/ block from this chapter, update at least two real fetch() calls from earlier chapters to relative paths, add the <base href="/predictor/"> tag, and confirm a request that previously would have resolved to the site root now correctly reaches the proxied FastAPI app instead.
📄 View solutionExplain, in your own words, why this course's own fetch-path fix, Django's FORCE_SCRIPT_NAME, and PHP's absolute-vs-relative redirect fix are described as three genuinely different mechanisms solving the same underlying issue — what is that shared underlying issue?
📄 View solutionExplain why the
Chapter 12 Quick Reference
- Path-mounted reverse proxy — one nginx
location /predictor/block usingproxy_passto Gunicorn, stripping the prefix - The real bug — absolute
fetch('/api/...')calls resolve from the site root, not the current page, and silently 404 once mounted at a sub-path - The fix — relative fetch paths (no leading slash) plus one shared
<base href="/predictor/">tag; localhost needs no base tag at all - Three sibling courses, three real mechanisms — this course's relative-path/base-tag fix, Django's FORCE_SCRIPT_NAME, PHP's absolute-vs-relative redirect — one shared underlying issue
- JSON endpoint — already exists, for free, at every route this course built; a real, honest next step for tighter Astro-embedded integration
- Still genuinely open — historical backfill, fixture rescheduling workflow, and single-vs-multi-user support, per project_issues.md P3