Navigation

FastAPI Rebuild › Chapter 5

Navigation & Site Structure

Eliminate boilerplate with a shared context dependency, add Jinja2 macros, and build prev/next page navigation

Chapter 5 of 10 Dependency Injection Jinja2 Macros Prev / Next Nav
Chapter Milestone

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.

Every route right now
@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 ...
    }
  )
After this chapter
@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.

app/dependencies.py  (new file) python
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,
        }
How 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.
Lazy loading subjects: the _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.

osztromok/ ├── app/ │ ├── main.py │ ├── database.py │ ├── models.py │ ├── dependencies.py <-- NEW: CommonContext class │ ├── routers/ │ │ └── cms.py <-- UPDATED: uses CommonContext, adds prev/next │ ├── templates/ │ │ ├── base.html │ │ ├── home.html │ │ ├── subject.html <-- UPDATED: uses macro │ │ ├── subtopic.html <-- UPDATED: uses macro │ │ ├── page.html <-- UPDATED: uses macro + prev/next │ │ ├── 404.html │ │ └── macros/ │ │ └── ui.html <-- NEW: breadcrumb + page_nav macros │ └── static/css/ │ └── main.css <-- UPDATED: breadcrumb + page-nav styles

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.

If you know Java: macros are the Jinja2 equivalent of Thymeleaf fragments (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.
app/templates/macros/ui.html jinja2
{# ── 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.

app/routers/cms.py — full updated version python
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.

app/templates/page.html — updated html + jinja2
{% 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:

app/static/css/main.css — append these rules 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

http://127.0.0.1:8000/japan/japanese-lessons/lesson-2
HomeJapanJapanese Lessons › Lesson 2: Numbers

Lesson 2: Numbers

The lesson content renders here — your styled HTML from the database, exactly as it was in the PHP version. Tables, vocab cards, audio — all rendered via | safe.
← Lesson 1: Greetings

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:

app/main.py — home route, final version python
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.pyCommonContext class injects Request + Session, lazy-loads subjects, auto-detects active nav slug from the URL
  • All routes simplifiedctx: CommonContext = Depends() replaces the repeated get_subjects() + manual dict building in every handler
  • app/templates/macros/ui.html — two macros: breadcrumb(crumbs) and page_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_subject parameter needed in route calls

Quick Reference — Chapter 5

ctx: CommonContext = Depends() Inject the shared context — no callable argument needed when type is annotated
ctx.get(page=page, subtopic=subtopic) Build full context dict — request, subjects, active_subject + extras merged in
{% macro name(args) %}…{% endmacro %} Define a reusable template fragment with parameters
{% from "macros/ui.html" import breadcrumb %} Import a macro from another template file
{{ breadcrumb(breadcrumbs) }} Call a macro — renders its HTML inline
request.url.path.strip("/").split("/") Extract URL segments to detect the active section
next((i for i, p in enumerate(pages) if p.id == id), None) Find index of current page in an ordered list
@property + lazy cache (_subjects = None) Query the DB once per request, only if the value is actually used