| safe.
Navigation
Navigation & Site Structure
Eliminate boilerplate with a shared context dependency, add Jinja2 macros, and build prev/next page navigation
By the end of this chapter every page has a consistent, highlighted navigation bar
and breadcrumb trail without duplicating code in each route. Pages within a subtopic
have Previous / Next buttons — so a visitor reading Lesson 1 can click straight
to Lesson 2. All of this is driven by a single injectable CommonContext dependency.
1 The boilerplate problem
Every route handler currently calls get_subjects(db) and manually builds
the same context dict. With three CMS routes plus the home route, that is already four
copies of the same pattern — and it will keep growing as we add admin routes in Chapter 8.
@router.get("/{s}/{st}/{p}") async def cms_page( s: str, st: str, p: str, request: Request, db: Session = Depends(get_db), ): subjects = get_subjects(db) active = s # ... route logic ... return templates.TemplateResponse( "page.html", { "request": request, "subjects": subjects, "active_subject": active, # ... page-specific keys ... } )
@router.get("/{s}/{st}/{p}") async def cms_page( s: str, st: str, p: str, ctx: CommonContext = Depends(), ): # ... route logic ... return templates.TemplateResponse( "page.html", ctx.get( # only page-specific keys here ) )
The right tool for this in FastAPI is a class-based dependency. Because
FastAPI supports Depends() on any callable — including class constructors —
we can write a class that receives Request and Session via
injection and exposes a .get() method that always produces the correct
context dict.
2 The CommonContext dependency
Create a new file app/dependencies.py. This keeps the dependency separate
from the database setup and makes it easy to import from any router.
from __future__ import annotations from typing import Optional from fastapi import Request, Depends from sqlalchemy.orm import Session from app.database import get_db, get_subjects class CommonContext: """Reusable dependency that builds the shared template context. Inject with: ctx: CommonContext = Depends() Use in TemplateResponse: templates.TemplateResponse("page.html", ctx.get(page=page, ...)) Provides automatically: - request → required by Jinja2Templates - subjects → all subjects for the nav bar (lazy-loaded) - active_subject → slug of the current top-level section (from URL) """ def __init__( self, request: Request, db: Session = Depends(get_db), ): self.request = request self.db = db self._subjects = None # lazy — only query if accessed # ── subjects ────────────────────────────────────────────────────────── @property def subjects(self): """Query subjects once and cache the result for this request.""" if self._subjects is None: self._subjects = get_subjects(self.db) return self._subjects # ── active_subject ──────────────────────────────────────────────────── @property def active_subject(self) -> str: """Derive the active nav slug from the URL path automatically. /japan/kanji/kanji-tiles → "japan" /french → "french" / → "" """ parts = self.request.url.path.strip("/").split("/") return parts[0] if parts and parts[0] else "" # ── get() ───────────────────────────────────────────────────────────── def get(self, **extra) -> dict: """Return the base context merged with any route-specific values. Usage: ctx.get(page=page, subtopic=subtopic) """ return { "request": self.request, "subjects": self.subjects, "active_subject": self.active_subject, **extra, }
Depends() with no arguments works: when FastAPI
sees ctx: CommonContext = Depends(), it uses the parameter's type
annotation (CommonContext) as the callable. It then inspects
CommonContext.__init__, finds request: Request and
db: Session = Depends(get_db), and injects them automatically — the
same way it would for a standalone function dependency. This is the class-based
dependency pattern, equivalent to Spring's @Autowired constructor
injection.
_subjects cache means the DB
query only runs if .subjects is actually accessed. On a future route that
doesn't need subjects (e.g. a JSON API endpoint), no query is issued at all —
even though CommonContext is still injected.
3 Updated project structure
Two new files join the project this chapter. Everything else stays in place.
4 Jinja2 macros — reusable template fragments
A Jinja2 macro is the template equivalent of a function — it takes
arguments and returns rendered HTML. We will use two: one for the breadcrumb trail
and one for the prev/next page navigation. By putting them in
macros/ui.html, any template can import and call them in one line.
th:fragment) or JSP custom tags. The
{% from "macros/ui.html" import breadcrumb %} call is like importing
a static utility method — the macro lives in one file and is reused everywhere.
{# ── breadcrumb(crumbs) ────────────────────────────────────────────────── Renders a breadcrumb nav from a list of {label, url} dicts. The last item is the current page — rendered as plain text, not a link. Usage: {% from "macros/ui.html" import breadcrumb %} {{ breadcrumb([ {"label": "Home", "url": "/"}, {"label": "Japan", "url": "/japan"}, {"label": "Kanji", "url": "/japan/kanji"}, {"label": "Kanji Tiles", "url": None}, ]) }} #} {% macro breadcrumb(crumbs) %} <nav class="breadcrumb" aria-label="breadcrumb"> {% for crumb in crumbs %} {% if loop.last %} <span class="bc-current">{{ crumb.label }}</span> {% else %} <a href="{{ crumb.url }}">{{ crumb.label }}</a> <span class="bc-sep" aria-hidden="true">›</span> {% endif %} {% endfor %} </nav> {% endmacro %} {# ── page_nav(prev_page, next_page, subject, subtopic) ──────────────────── Renders Previous / Next buttons at the bottom of a content page. Pass None for prev_page on the first page, None for next_page on the last. Usage: {% from "macros/ui.html" import page_nav %} {{ page_nav(prev_page, next_page, subject, subtopic) }} #} {% macro page_nav(prev_page, next_page, subject, subtopic) %} {% if prev_page or next_page %} <nav class="page-nav" aria-label="page navigation"> {% if prev_page %} <a href="/{{ subject.slug }}/{{ subtopic.slug }}/{{ prev_page.slug }}" class="page-nav-btn page-nav-prev"> <span class="page-nav-arrow">←</span> <span> <span class="page-nav-label">Previous</span> <span class="page-nav-title">{{ prev_page.name }}</span> </span> </a> {% else %} <div></div> {# spacer to keep next button on the right #} {% endif %} {% if next_page %} <a href="/{{ subject.slug }}/{{ subtopic.slug }}/{{ next_page.slug }}" class="page-nav-btn page-nav-next"> <span> <span class="page-nav-label">Next</span> <span class="page-nav-title">{{ next_page.name }}</span> </span> <span class="page-nav-arrow">→</span> </a> {% endif %} </nav> {% endif %} {% endmacro %}
5 Update cms.py — CommonContext + prev/next logic
Replace the old base_context() helper with the injected
CommonContext, and add the previous/next page calculation to the
cms_page route. The breadcrumb data is now built in the route
rather than computed in the template — keeping templates as logic-free as possible.
from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select from app.dependencies import CommonContext from app.models import Subject, Subtopic, Page, PageContent router = APIRouter() templates = Jinja2Templates(directory="app/templates") # ── helpers ────────────────────────────────────────────────────────────── def _get_subject(ctx: CommonContext, slug: str) -> Subject: obj = ctx.db.execute( select(Subject).where(Subject.slug == slug) ).scalar_one_or_none() if not obj: raise HTTPException(status_code=404) return obj def _get_subtopic(ctx: CommonContext, slug: str, subject_id: int) -> Subtopic: obj = ctx.db.execute( select(Subtopic).where( Subtopic.slug == slug, Subtopic.subject_id == subject_id, ) ).scalar_one_or_none() if not obj: raise HTTPException(status_code=404) return obj def _prev_next(pages, current_id: int): """Given an ordered list of Page objects and the current page id, return (prev_page, next_page) — either may be None.""" idx = next((i for i, p in enumerate(pages) if p.id == current_id), None) if idx is None: return None, None prev_page = pages[idx - 1] if idx > 0 else None next_page = pages[idx + 1] if idx < len(pages) - 1 else None return prev_page, next_page # ═══════════════════════════════════════════════════════════════════════════ # Subject index → /japan/ # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}", response_class=HTMLResponse) async def subject_index( subject_slug: str, ctx: CommonContext = Depends(), ): subject = _get_subject(ctx, subject_slug) return templates.TemplateResponse( "subject.html", ctx.get( subject=subject, breadcrumbs=[ {"label": "Home", "url": "/"}, {"label": subject.name, "url": None}, ], ), ) # ═══════════════════════════════════════════════════════════════════════════ # Subtopic index → /japan/kanji/ # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}/{subtopic_slug}", response_class=HTMLResponse) async def subtopic_index( subject_slug: str, subtopic_slug: str, ctx: CommonContext = Depends(), ): subject = _get_subject(ctx, subject_slug) subtopic = _get_subtopic(ctx, subtopic_slug, subject.id) return templates.TemplateResponse( "subtopic.html", ctx.get( subject=subject, subtopic=subtopic, breadcrumbs=[ {"label": "Home", "url": "/"}, {"label": subject.name, "url": f"/{subject.slug}"}, {"label": subtopic.name, "url": None}, ], ), ) # ═══════════════════════════════════════════════════════════════════════════ # Page → /japan/kanji/kanji-tiles # ═══════════════════════════════════════════════════════════════════════════ @router.get("/{subject_slug}/{subtopic_slug}/{page_slug}", response_class=HTMLResponse) async def cms_page( subject_slug: str, subtopic_slug: str, page_slug: str, ctx: CommonContext = Depends(), ): subject = _get_subject(ctx, subject_slug) subtopic = _get_subtopic(ctx, subtopic_slug, subject.id) page = ctx.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) content_sections = ctx.db.execute( select(PageContent) .where(PageContent.page_id == page.id) .order_by(PageContent.display_order) ).scalars().all() # ── prev / next within this subtopic ────────────────────────────────── # subtopic.pages is already ordered by display_order (set in the model) prev_page, next_page = _prev_next(subtopic.pages, page.id) return templates.TemplateResponse( "page.html", ctx.get( subject=subject, subtopic=subtopic, page=page, content_sections=content_sections, prev_page=prev_page, next_page=next_page, breadcrumbs=[ {"label": "Home", "url": "/"}, {"label": subject.name, "url": f"/{subject.slug}"}, {"label": subtopic.name, "url": f"/{subject.slug}/{subtopic.slug}"}, {"label": page.name, "url": None}, ], ), )
6 Updated page.html — macros in action
Import the two macros at the top of the template and call them where the breadcrumb and page navigation should appear. The rest of the page body is unchanged from Chapter 4.
{% extends "base.html" %} {% from "macros/ui.html" import breadcrumb, page_nav %} {% block title %}{{ page.name }} — {{ subject.name }} — osztromok.com{% endblock %} {% block content %} <div class="container"> {# Breadcrumb — data comes from the route, rendered by the macro #} {{ breadcrumb(breadcrumbs) }} <div class="page-body"> <h1>{{ page.name }}</h1> {% for section in content_sections %} <div class="page-content"> {{ section.content | safe }} </div> {% else %} <p style="color:#6e7681">This page has no content yet.</p> {% endfor %} </div> {# Prev/Next — None values are handled inside the macro #} {{ page_nav(prev_page, next_page, subject, subtopic) }} </div> {% endblock %}
Apply the same macro import to subject.html and
subtopic.html — replace the inline breadcrumb HTML with
{% from "macros/ui.html" import breadcrumb %} at the top and
{{ breadcrumb(breadcrumbs) }} in the content block.
7 CSS additions for breadcrumb and prev/next nav
Add these rules to the bottom of app/static/css/main.css:
/* ── Breadcrumb ───────────────────────────────────────────────────────────── */ .breadcrumb { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 0.78rem; color: var(--clr-dim); padding: 16px 0 6px; } .breadcrumb a { color: var(--clr-muted); text-decoration: none; transition: color 0.15s; } .breadcrumb a:hover { color: var(--clr-text); } .bc-sep { color: var(--clr-border); user-select: none; } .bc-current { color: var(--clr-text); font-weight: 500; } /* ── Prev / Next page navigation ───────────────────────────────────────────── */ .page-nav { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--clr-border); } .page-nav-btn { display: flex; align-items: center; gap: 12px; padding: 12px 18px; border: 1px solid var(--clr-border); border-radius: var(--radius); text-decoration: none; color: var(--clr-muted); background: var(--clr-surface); transition: border-color 0.2s, color 0.2s, transform 0.15s; max-width: 48%; } .page-nav-btn:hover { border-color: var(--clr-accent); color: var(--clr-text); transform: translateY(-1px); } .page-nav-next { flex-direction: row-reverse; margin-left: auto; border-color: rgba(0,180,216,0.3); color: var(--clr-accent); } .page-nav-arrow { font-size: 1.1rem; flex-shrink: 0; } .page-nav-label { display: block; font-size: 0.67rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--clr-dim); margin-bottom: 2px; } .page-nav-title { display: block; font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } @media (max-width: 480px) { .page-nav-title { display: none; } .page-nav-btn { padding: 10px 14px; } }
8 What a content page looks like now
Lesson 2: Numbers
The "Japan" nav link is highlighted because active_subject
is derived automatically from the URL path — no manual passing required.
The breadcrumb is built in the route handler and rendered
by the macro. The Previous / Next buttons appear only when there
are adjacent pages, and the "Next" button uses a teal accent to guide the reader forward.
9 Update the home route in main.py
Switch the home route to use CommonContext too — it eliminates the last
manual get_subjects() call:
from app.dependencies import CommonContext @app.get("/", response_class=HTMLResponse) async def home(ctx: CommonContext = Depends()): return templates.TemplateResponse("home.html", ctx.get()) # The 404 handler stays the same — it can't use Depends(), so it opens # a raw SessionLocal() as before.
✓ Chapter 5 Complete — Milestone reached
- app/dependencies.py —
CommonContextclass injects Request + Session, lazy-loads subjects, auto-detects active nav slug from the URL - All routes simplified —
ctx: CommonContext = Depends()replaces the repeated get_subjects() + manual dict building in every handler - app/templates/macros/ui.html — two macros:
breadcrumb(crumbs)andpage_nav(prev, next, subject, subtopic) - Breadcrumbs built in route handlers (not in templates), rendered by the macro — Home › Subject › Subtopic › Page on every content page
- Prev/Next navigation —
_prev_next()helper calculates adjacent pages; buttons appear automatically on multi-page subtopics - Active nav highlighting — derived from the URL path, no manual
active_subjectparameter needed in route calls