Skip to content

Connecting a static Frontend

Release Tracker app ships a small dashboard that uses the API.


Warning

In production, you generally wouldn't want to bundle a frontend with your API. This is for demo only.

Let's look at how the static files get served and how the /app URL routes to the dashboard.


Static Files in release_tracker/static/

The final app keeps the dashboard inside the Python package:

Text
src/release_tracker/
├── __init__.py
├── config.py
# ...
└── static/
    ├── index.html
    ├── styles.css
    └── app.js

Co-locating the static assets with the Python source has a useful consequence: the package install includes them. When the Docker image runs uv sync, the static/ directory ends up inside the venv, reachable at a stable path regardless of where the container runs.

Mounting the Static Directory

main.py mounts the static/ directory under /app/static:

src/release_tracker/main.py
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

static_dir = Path(__file__).resolve().parent / "static"

app = FastAPI(...)

# ... routers, middleware ...

app.mount("/app/static", StaticFiles(directory=static_dir), name="app-static")

StaticFiles is a FastAPI / Starlette utility that serves files from a directory. Mounting it at /app/static means a request to /app/static/styles.css returns the file at <package>/static/styles.css.

Path(__file__).resolve().parent / "static" builds the path relative to main.py's location, which is the same path inside the Docker image as on a developer machine. Hard-coding /app/static would work in the container but break locally.

Routing /app to index.html

The dashboard is a single-page app. A user hitting http://example.com/app should get index.html, which then loads its components and CSS from /app/static/.

The wiring uses a FileResponse:

src/release_tracker/main.py
from fastapi.responses import FileResponse


@app.get("/app", include_in_schema=False)
@app.get("/app/", include_in_schema=False)
def frontend_dashboard() -> FileResponse:
    return FileResponse(static_dir / "index.html")

Two route registrations cover both /app and /app/, since browsers don't always normalize the trailing slash. include_in_schema=False keeps the route out of the OpenAPI docs at /docs. There's no useful API surface to document for a static-file response.

How the Dashboard Reads from the API

The dashboard is loaded over the same origin as the API (http://localhost:8000/app), and it only makes two requests:

JavaScript
// roughly what app.js does
const projects = await fetch("/projects/").then(r => r.json());
const tasks = await fetch(`/tasks?${query}`).then(r => r.json());

Both endpoints are public (reads aren't protected), so no Authorization header is needed. Same-origin GETs don't trip CORS, so no CORS configuration is needed either. This dashboard is read-only.

Anything that mutates state creating, updating, etc, has to go thorugh an authenticated client.

Cross-origin Setups Need CORSMiddleware

The setup above is simple because the frontend and API share an origin.

When they don't (app.example.com for the frontend, api.example.com for the API), the browser starts blocking the API calls unless the API explicitly opts in with CORS headers.

FastAPI ships CORSMiddleware for that case:

src/release_tracker/main.py (separate-origin example)
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

We don't need this because everything is on one origin.

A separate-origin deployment would add it before the routers are included, with allow_origins listing the exact origins the frontend is served from.