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:

OptionWhat It Looks LikeReal 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:

/etc/nginx/sites-available/example.com (excerpt)
server { listen 443 ssl; server_name example.com; # The existing Astro static site root /var/www/example.com/dist; index index.html; location / { try_files $uri $uri/ =404; } # The predictor, mounted at /predictor/, proxied to Gunicorn location /predictor/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }

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.

The real bug: absolute fetch() paths silently break, and localhost never catches it
Every script since Chapter 4 calls 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:

// Before (Chapter 4 onward) — absolute, breaks once mounted at a sub-path fetch(`/api/seasons/${seasonId}/teams`); // After — relative, resolves against the page's own URL fetch(`api/seasons/${seasonId}/teams`);

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:

static/index.html (and every other page)
<head> <base href="/predictor/"> ... </head>
A third genuinely different mechanism, same underlying issue as its two sibling courses
Personal Catalogue (PHP & MySQL)'s own capstone found absolute 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.
Local development needs no base tag at all
On 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

ChapterWhat It Contributed
1The real project spec (4 real prediction sources, two league tables, promotion/relegation) and a working FastAPI + SQLAlchemy + PostgreSQL connection
2The real Team/Season/SeasonTeam/Gameweek/Fixture schema, the dual self-referencing foreign key fix on Fixture, and nullable scores as "not yet played"
3Season/team admin routes, the reuse-vs-create Team lookup keeping a relegated-then-promoted club as one permanent row, and the 20-team cap
4The click-to-pair fixture-entry UI, and the real server-side duplicate-team guard client-side disabling only assists
5The Prediction table, a real PostgreSQL partial unique index allowing multiple guests but one user/expert/AI prediction, and the upsert pattern
6The real, confirmed point values (40/10), score_prediction(), and the guest-averaging problem resolved by averaging points, not scorelines
7The real league table — a UNION ALL normalizing home/away rows, LEFT JOIN from SeasonTeam so an unplayed team still gets a real row
8The prediction league table — a two-pass CTE averaging guest points per fixture before summing across the season
9Promotion/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
10The gameweek/season selector, and a genuine fix to Chapter 4's own flagged usedTeamIds gap
11Deployment — gunicorn workers, a real connection-pool sizing calculation, and the /docs production caveat
12Mounting 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

Exercise 1

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 solution
Exercise 2

Explain, 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 solution
Exercise 3

Explain why the tag fix works without ever needing to change on the localhost:8000 version of the app, tracing the explanation back to how a browser resolves a relative URL.

📄 View solution

Chapter 12 Quick Reference

  • Path-mounted reverse proxy — one nginx location /predictor/ block using proxy_pass to 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
Course Complete — Premier League Predictor: FastAPI & PostgreSQL (12/12 chapters)