Routing
Core CMS Routing
The three-segment route that maps URL slugs to database content — plus clean 404 handling
By the end of this chapter the URL /japan/kanji/kanji-tiles (or any valid subject/subtopic/page combination) will query your database, load the stored HTML content, and render a full page. Invalid slugs at any level return a styled 404 page. Subject and subtopic index pages are also wired up.
1 The complete URL map
Your CMS needs four URL patterns. They share the same slug variables but serve different levels of the hierarchy. The order they are registered matters — FastAPI matches routes top to bottom, so we register them from most specific to most general.
/japan/kanji/{character} that sits
above the generic three-segment route. Because FastAPI matches top-to-bottom, it
will be tested first and intercept those URLs before they hit the general CMS pattern.
For now, three levels are enough.
2 Creating the CMS router
Rather than adding all routes to main.py, we use FastAPI's
APIRouter to group the CMS routes in their own file.
This is the same separation-of-concerns pattern as Spring's
@Controller classes — each file owns a logical group of routes.
from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from sqlalchemy import select from app.database import get_db, get_subjects from app.models import Subject, Subtopic, Page, PageContent # APIRouter is to FastAPI what @Controller is to Spring MVC. # No prefix — routes are registered from the root path. router = APIRouter() templates = Jinja2Templates(directory="app/templates") # ── Shared helper: build the common template context ───────────────────── # Every CMS page needs subjects (for the nav) and breadcrumb data. # Centralising it here avoids repeating the same 4 dict keys in each route. def base_context(request: Request, db: Session, **extra) -> dict: return { "request": request, "subjects": get_subjects(db), **extra, } # ═══════════════════════════════════════════════════════════════════════════ # Route 1 — Subject index: /japan/ # Lists all subtopics for a subject. # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}", response_class=HTMLResponse) async def subject_index( subject_slug: str, request: Request, db: Session = Depends(get_db), ): subject = db.execute( select(Subject).where(Subject.slug == subject_slug) ).scalar_one_or_none() if not subject: raise HTTPException(status_code=404, detail=f"Subject '{subject_slug}' not found") return templates.TemplateResponse( "subject.html", base_context( request, db, subject=subject, active_subject=subject_slug, ), ) # ═══════════════════════════════════════════════════════════════════════════ # Route 2 — Subtopic index: /japan/kanji/ # Lists all pages within a subtopic. # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}/{subtopic_slug}", response_class=HTMLResponse) async def subtopic_index( subject_slug: str, subtopic_slug: str, request: Request, db: Session = Depends(get_db), ): subject = db.execute( select(Subject).where(Subject.slug == subject_slug) ).scalar_one_or_none() if not subject: raise HTTPException(status_code=404) # The subtopic must belong to THIS subject — prevents slug collisions # across subjects (two subjects could have a subtopic both named "intro") subtopic = db.execute( select(Subtopic).where( Subtopic.slug == subtopic_slug, Subtopic.subject_id == subject.id, ) ).scalar_one_or_none() if not subtopic: raise HTTPException(status_code=404) return templates.TemplateResponse( "subtopic.html", base_context( request, db, subject=subject, subtopic=subtopic, active_subject=subject_slug, ), ) # ═══════════════════════════════════════════════════════════════════════════ # Route 3 — Page content: /japan/kanji/kanji-tiles # The core CMS route — loads and renders a single page from the database. # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}/{subtopic_slug}/{page_slug}", response_class=HTMLResponse) async def cms_page( subject_slug: str, subtopic_slug: str, page_slug: str, request: Request, db: Session = Depends(get_db), ): # ── Step 1: validate subject slug ───────────────────────────────── subject = db.execute( select(Subject).where(Subject.slug == subject_slug) ).scalar_one_or_none() if not subject: raise HTTPException(status_code=404) # ── Step 2: validate subtopic (must belong to this subject) ─────── subtopic = db.execute( select(Subtopic).where( Subtopic.slug == subtopic_slug, Subtopic.subject_id == subject.id, ) ).scalar_one_or_none() if not subtopic: raise HTTPException(status_code=404) # ── Step 3: validate page (must belong to this subtopic) ────────── page = db.execute( select(Page).where( Page.slug == page_slug, Page.subtopic_id == subtopic.id, ) ).scalar_one_or_none() if not page: raise HTTPException(status_code=404) # ── Step 4: load content sections ordered by display_order ──────── content_sections = db.execute( select(PageContent) .where(PageContent.page_id == page.id) .order_by(PageContent.display_order) ).scalars().all() return templates.TemplateResponse( "page.html", base_context( request, db, subject=subject, subtopic=subtopic, page=page, content_sections=content_sections, active_subject=subject_slug, ), )
Subtopic.subject_id == subject.id
(and Page.subtopic_id == subtopic.id) rather than just matching on the slug alone.
Without this, a URL like /japan/grammar/kanji-tiles would return
the kanji-tiles page even though it belongs to the kanji subtopic, not
grammar — because the page slug alone would still match. The hierarchy check is
what makes slugs meaningful.
3 What happens for every page request
Here is the full request flow for GET /japan/kanji/kanji-tiles.
Any step that returns None from the database raises a 404.
4 Custom 404 error page
By default, FastAPI returns a JSON body for HTTPException. For a
website, we want an HTML page instead. Register an exception handler on the
app object in main.py:
from fastapi import FastAPI, Request, Depends, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from app import models # noqa: F401 from app.database import get_db, get_subjects from app.models import Subject from app.routers import cms app = FastAPI(title="osztromok.com") templates = Jinja2Templates(directory="app/templates") app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/resources", StaticFiles(directory="resources"), name="resources") # ── Include the CMS router ──────────────────────────────────────────────── # No prefix — routes are registered exactly as written in cms.py # Include AFTER static mounts so /static/ and /resources/ are never # accidentally caught by the {subject_slug} catch-all route. app.include_router(cms.router) # ── Home route (stays in main.py) ──────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def home(request: Request, db: Session = Depends(get_db)): subjects = get_subjects(db) return templates.TemplateResponse( "home.html", {"request": request, "subjects": subjects} ) # ── 404 handler — renders an HTML page instead of JSON ─────────────────── # @app.exception_handler catches HTTPException raises anywhere in the app. # We can still access the DB here to populate the nav subjects. @app.exception_handler(404) async def not_found(request: Request, exc: HTTPException): # Open a fresh session (we can't use Depends() in exception handlers) from app.database import SessionLocal db = SessionLocal() try: subjects = get_subjects(db) finally: db.close() return templates.TemplateResponse( "404.html", {"request": request, "subjects": subjects}, status_code=404, )
Depends(get_db)?
FastAPI's dependency injection is wired to route handlers — exception handlers
are called outside the normal request lifecycle, after the route's dependencies
have already been closed. We open a fresh SessionLocal() manually
and close it in a finally block.
5 The four new templates
app/templates/page.html — the main CMS page
This template renders the actual content of a page. The key line is
{{ section.content | safe }} — it injects the raw HTML stored in
page_content.content directly into the page without escaping it.
This is how your existing styled lesson content (kanji tiles, Japanese lessons, etc.)
will appear exactly as it does now.
{% extends "base.html" %} {% block title %}{{ page.name }} — {{ subject.name }} — osztromok.com{% endblock %} {% block content %} <div class="container"> {# ── Breadcrumb ────────────────────────────────────────────── #} <nav class="breadcrumb" aria-label="breadcrumb"> <a href="/">Home</a> › <a href="/{{ subject.slug }}">{{ subject.name }}</a> › <a href="/{{ subject.slug }}/{{ subtopic.slug }}">{{ subtopic.name }}</a> › {{ page.name }} </nav> {# ── Page header ───────────────────────────────────────────── #} <div class="page-body"> <h1>{{ page.name }}</h1> {# ── Content sections ──────────────────────────────────────── #} {# Each row in page_content is rendered in display_order. The | safe filter renders HTML as-is — this is how your styled lesson fragments (kanji tiles, vocabulary tables, etc.) appear. #} {% for section in content_sections %} {% if section.content_type == 'html' %} <div class="page-content"> {{ section.content | safe }} </div> {% elif section.content_type == 'markdown' %} {# Markdown rendering — add python-markdown in Chapter 8 if needed #} <div class="page-content">{{ section.content | safe }}</div> {% endif %} {% else %} <p style="color:#6e7681">This page has no content yet.</p> {% endfor %} </div>{# .page-body #} </div>{# .container #} {% endblock %}
app/templates/subject.html — subject index
{% extends "base.html" %} {% block title %}{{ subject.name }} — osztromok.com{% endblock %} {% block content %} <div class="container"> <nav class="breadcrumb"> <a href="/">Home</a> › {{ subject.name }} </nav> <div class="subject-section"> {% if subject.icon %} <div style="font-size:2.5rem;margin-bottom:8px">{{ subject.icon }}</div> {% endif %} <h1 style="margin-bottom:6px">{{ subject.name }}</h1> {% if subject.description %} <p style="color:#8b949e;margin-bottom:28px">{{ subject.description }}</p> {% endif %} <p class="section-title">{{ subject.subtopics | length }} subtopics</p> <div class="subject-grid"> {% for subtopic in subject.subtopics %} <a href="/{{ subject.slug }}/{{ subtopic.slug }}" class="subject-card"> <div class="card-name">{{ subtopic.name }}</div> {% if subtopic.description %} <div class="card-desc">{{ subtopic.description | truncate(90) }}</div> {% endif %} <div class="card-desc" style="margin-top:8px;color:#6e7681"> {{ subtopic.pages | length }} page{% if subtopic.pages | length != 1 %}s{% endif %} </div> </a> {% endfor %} </div> </div> </div> {% endblock %}
app/templates/subtopic.html — page listing
{% extends "base.html" %} {% block title %}{{ subtopic.name }} — {{ subject.name }} — osztromok.com{% endblock %} {% block content %} <div class="container"> <nav class="breadcrumb"> <a href="/">Home</a> › <a href="/{{ subject.slug }}">{{ subject.name }}</a> › {{ subtopic.name }} </nav> <div class="subject-section"> <h1 style="margin-bottom:6px">{{ subtopic.name }}</h1> {% if subtopic.description %} <p style="color:#8b949e;margin-bottom:28px">{{ subtopic.description }}</p> {% endif %} <p class="section-title">{{ subtopic.pages | length }} page{% if subtopic.pages | length != 1 %}s{% endif %}</p> <div class="subject-grid"> {% for pg in subtopic.pages %} <a href="/{{ subject.slug }}/{{ subtopic.slug }}/{{ pg.slug }}" class="subject-card"> <div class="card-name">{{ pg.name }}</div> </a> {% else %} <p style="color:#6e7681">No pages in this subtopic yet.</p> {% endfor %} </div> </div> </div> {% endblock %}
app/templates/404.html — error page
{% extends "base.html" %} {% block title %}404 — Page Not Found — osztromok.com{% endblock %} {% block content %} <div style="text-align:center;padding:80px 24px"> <div style="font-size:5rem;margin-bottom:16px">404</div> <h1 style="color:#00b4d8;font-size:1.4rem;margin-bottom:10px"> Page not found </h1> <p style="color:#8b949e;max-width:400px;margin:0 auto 28px"> The URL you requested doesn’t match any subject, subtopic, or page in the database. Check the spelling or start from the home page. </p> <a href="/" style="display:inline-block;background:#00b4d8;color:#000; font-weight:700;padding:10px 24px;border-radius:6px; text-decoration:none;font-size:.9rem"> ← Back to home </a> </div> {% endblock %}
6 Testing the routes
With the dev server running, test these URLs using your actual slugs from the database. Replace the example slugs with ones that match your data:
# Home — should show subject cards http://127.0.0.1:8000/ # Subject index — should show subtopic cards for Japan http://127.0.0.1:8000/japan # Subtopic index — should list pages in the Kanji subtopic http://127.0.0.1:8000/japan/kanji # Page content — should render your stored HTML (kanji tile grid) http://127.0.0.1:8000/japan/kanji/kanji-tiles # 404 — should render your styled 404 page (not a JSON error) http://127.0.0.1:8000/japan/kanji/this-page-does-not-exist http://127.0.0.1:8000/does-not-exist
Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| JSON {"detail":"Not Found"} instead of HTML 404 | Exception handler not registered, or registered after include_router | Move @app.exception_handler(404) to main.py, before include_router |
| Page renders but nav is empty | subjects not in template context | Ensure get_subjects(db) is called and "subjects" key is in the dict |
| Content renders as escaped HTML text | Missing | safe on content output |
Change {{ section.content }} to {{ section.content | safe }} |
| /japan/kanji/kanji-tiles returns 404 unexpectedly | Slug mismatch between URL and database value | Check exact slug values: SELECT slug FROM pages; in MySQL |
| Static CSS not loading on page | StaticFiles mount order or wrong path in template | Confirm url_for('static', path='css/main.css') in base.html head |
✓ Chapter 4 Complete — Milestone reached
- app/routers/cms.py — three routes handle /{subject}, /{subject}/{subtopic}, and /{subject}/{subtopic}/{page}
- Slug validation — each level checks that slugs belong to their parent, preventing cross-hierarchy collisions
- 404 handling — any invalid slug at any level raises HTTPException and renders a styled HTML page
- page.html — renders
page_contentHTML with| safe; multiple content sections stacked in display order - subject.html / subtopic.html — index pages with breadcrumb navigation
- 404.html — styled error page with a return-home button
- All your existing CMS content is now accessible via FastAPI — same URLs, same rendered output