Jinja2

FastAPI Rebuild › Chapter 3

Jinja2 Templates

Build the base layout, site CSS, and a home page that renders live data from the database

Chapter 3 of 10 Jinja2 2.x Template Inheritance Static Files
Chapter Milestone

By the end of this chapter you will have a base template with navigation and shared layout, a site stylesheet served from /static/css/, and a home page route that queries your database and renders your real subjects as clickable cards — exactly like your current PHP home page, but built on FastAPI.

1 Template inheritance — the key concept

Your PHP site most likely has a header.php and footer.php that every page includes with require_once. Jinja2's approach is more powerful: one base template defines the full HTML skeleton and marks regions as {% block %} placeholders. Child templates {% extends %} the base and fill in only those blocks — everything else (nav, footer, CSS links) is inherited automatically.

If you know Java: Jinja2 template inheritance is identical in concept to Thymeleaf's th:fragment / th:replace layout pattern, or JSP tiles. The {% extends %} + {% block %} pair is exactly ui:define + ui:composition in JSF Facelets.

PHP includes vs Jinja2 inheritance

PHP approachJinja2 approach
<?php require 'header.php'; ?>{% extends "base.html" %}
<?php require 'footer.php'; ?>(inherited — nothing needed)
echo $subject->name;{{ subject.name }}
foreach ($subjects as $s){% for s in subjects %}
if ($x): … endif;{% if x %} … {% endif %}
htmlspecialchars($str){{ str }} (auto-escaped)
echo strtoupper($str);{{ str | upper }}
/static/css/main.css{{ url_for('static', path='css/main.css') }}
Auto-escaping: Jinja2 escapes HTML characters in {{ }} expressions by default — so <script> in a database field becomes &lt;script&gt; in the output. To render trusted HTML (like your page_content data), use {{ content | safe }}. Never use | safe on user-submitted text.

2 Configure Jinja2 in main.py

FastAPI provides a Jinja2Templates wrapper that connects Jinja2 to your app/templates/ folder and adds url_for() as a global function inside every template. Update app/main.py with the following:

app/main.py — full updated version python
from fastapi import FastAPI, Request, Depends
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import select
from app import models  # noqa: F401 — registers ORM models
from app.database import get_db
from app.models import Subject

# ── App instance ─────────────────────────────────────────────────────────
app = FastAPI(title="osztromok.com")

# ── Static file mounts ───────────────────────────────────────────────────
app.mount("/static",    StaticFiles(directory="app/static"),  name="static")
app.mount("/resources", StaticFiles(directory="resources"),    name="resources")

# ── Jinja2 templates ─────────────────────────────────────────────────────
# Point Jinja2 at our templates folder.
# url_for() becomes available inside every template automatically.
templates = Jinja2Templates(directory="app/templates")

# ── Home page ────────────────────────────────────────────────────────────
# Request must be the first parameter in any route that uses templates.
# db is injected by FastAPI via Depends(get_db) — see Chapter 2.
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, db: Session = Depends(get_db)):
    subjects = (
        db.execute(select(Subject).order_by(Subject.display_order))
        .scalars()
        .all()
    )
    # TemplateResponse takes: template name + a context dict.
    # "request" MUST be in the context — Jinja2Templates requires it
    # so that url_for() works inside templates.
    return templates.TemplateResponse(
        "home.html",
        {"request": request, "subjects": subjects},
    )
request must always be in the context dict. Jinja2Templates injects url_for() and request as template globals, but only if you pass the request object. If you forget it, you will get a KeyError: 'request' when any template tries to call url_for() — even in the base template. This is the most common gotcha for FastAPI beginners.

3 Site stylesheet — app/static/css/main.css

Create the main CSS file. This gives the rebuild its visual identity — a clean dark theme that you can adjust to match your existing site's look. The classes here are referenced by the base template and home template below.

app/static/css/main.css css
/* ── Reset & root variables ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --clr-bg:       #0d1117;
  --clr-surface:  #161b22;
  --clr-border:   #30363d;
  --clr-accent:   #00b4d8;   /* teal — primary links, hover states */
  --clr-accent2:  #f7c948;   /* amber — secondary highlights */
  --clr-text:     #e6edf3;
  --clr-muted:    #8b949e;
  --clr-dim:      #6e7681;
  --nav-height:   56px;
  --content-max:  1100px;
  --radius:       8px;
}

body {
  background:  var(--clr-bg);
  color:       var(--clr-text);
  font-family: 'Segoe UI', system-ui, sans-serif;
  line-height: 1.6;
  min-height:  100vh;
  display:     flex;
  flex-direction: column;
}

/* ── Navigation ───────────────────────────────────────────────────────────── */
.site-nav {
  background:   var(--clr-surface);
  border-bottom: 1px solid var(--clr-border);
  height:       var(--nav-height);
  display:      flex;
  align-items:  center;
  padding:      0 24px;
  position:     sticky;
  top:          0;
  z-index:      100;
  gap:          32px;
}

.nav-brand {
  font-family:  'Consolas', monospace;
  font-size:    1rem;
  font-weight:  700;
  color:        var(--clr-accent);
  text-decoration: none;
  flex-shrink:  0;
}

.nav-links {
  display:      flex;
  gap:          4px;
  overflow-x:   auto;
  flex:         1;
}

.nav-link {
  color:        var(--clr-muted);
  text-decoration: none;
  font-size:    0.85rem;
  padding:      5px 10px;
  border-radius: 5px;
  transition:   color 0.15s, background 0.15s;
  white-space:  nowrap;
}

.nav-link:hover, .nav-link.active {
  color:       var(--clr-text);
  background: rgba(255,255,255,0.06);
}

/* ── Main content ─────────────────────────────────────────────────────────── */
.site-main { flex: 1; }

.container {
  max-width: var(--content-max);
  margin:    0 auto;
  padding:   0 24px;
}

/* ── Home hero ────────────────────────────────────────────────────────────── */
.home-hero {
  background: linear-gradient(135deg, #0d1117 0%, #0a1628 60%, #061020 100%);
  border-bottom: 1px solid var(--clr-border);
  padding:       60px 24px 50px;
  text-align:    center;
}

.home-hero h1 { font-size: 2.4rem; font-weight: 700; margin-bottom: 10px; }
.home-hero h1 span { color: var(--clr-accent); }
.home-hero p { color: var(--clr-muted); font-size: 1rem; max-width: 500px; margin: 0 auto; }

/* ── Subject grid ─────────────────────────────────────────────────────────── */
.subject-section { padding: 40px 0 60px; }

.section-title {
  font-size:     0.7rem;
  font-weight:   700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color:         var(--clr-dim);
  margin-bottom: 18px;
}

.subject-grid {
  display:               grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap:                   16px;
}

.subject-card {
  background:   var(--clr-surface);
  border:       1px solid var(--clr-border);
  border-radius: var(--radius);
  padding:      24px 20px;
  text-decoration: none;
  color:        var(--clr-text);
  display:      block;
  transition:   border-color 0.2s, transform 0.15s, box-shadow 0.15s;
}

.subject-card:hover {
  border-color: var(--clr-accent);
  transform:    translateY(-3px);
  box-shadow:   0 8px 24px rgba(0,0,0,0.35);
}

.card-icon  { font-size: 2rem; margin-bottom: 10px; display: block; }
.card-name  { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
.card-desc  { font-size: 0.82rem; color: var(--clr-muted); line-height: 1.45; }

/* ── Page content area (Chapter 4) ────────────────────────────────────────── */
.page-body { padding: 36px 0 60px; }

.page-body h1 { font-size: 1.6rem; margin-bottom: 20px; color: var(--clr-accent); }

.page-content {
  line-height:  1.75;
  color:        var(--clr-muted);
}

.page-content h2 { color: var(--clr-text); margin: 28px 0 10px; }
.page-content p  { margin-bottom: 14px; }
.page-content a  { color: var(--clr-accent); }

/* ── Footer ───────────────────────────────────────────────────────────────── */
.site-footer {
  border-top:  1px solid var(--clr-border);
  padding:     20px 24px;
  text-align:  center;
  font-size:   0.78rem;
  color:       var(--clr-dim);
}

/* ── Utility ──────────────────────────────────────────────────────────────── */
.breadcrumb { font-size: 0.8rem; color: var(--clr-dim); padding: 14px 0 4px; }
.breadcrumb a { color: var(--clr-muted); text-decoration: none; }
.breadcrumb a:hover { color: var(--clr-text); }

@media (max-width: 640px) {
  .home-hero h1 { font-size: 1.7rem; }
  .site-nav { gap: 16px; }
}

4 Base template — app/templates/base.html

This is the master layout for the entire site. Every other template will extend it. The navigation bar queries subjects from the context, and each {% block %} is a region that child templates can override.

app/templates/base.html html + jinja2
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  {# Each child template can override the title block #}
  <title>{% block title %}osztromok.com{% endblock %}</title>

  {# url_for() generates the correct path to static files #}
  <link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}">

  {# Extra CSS or meta tags specific to a child template #}
  {% block extra_head %}{% endblock %}
</head>

<body>

  {# ── Navigation ─────────────────────────────────────────────────── #}
  <nav class="site-nav">
    <a href="/" class="nav-brand">osztromok.com</a>

    <div class="nav-links">
      {% for subject in subjects %}
        {# Mark the current subject's nav link as active #}
        <a href="/{{ subject.slug }}"
           class="nav-link {% if active_subject == subject.slug %}active{% endif %}">
          {{ subject.name }}
        </a>
      {% endfor %}
    </div>
  </nav>

  {# ── Page content ───────────────────────────────────────────────── #}
  <main class="site-main">
    {% block content %}{% endblock %}
  </main>

  {# ── Footer ─────────────────────────────────────────────────────── #}
  <footer class="site-footer">
    © {{ now.year if now else 2025 }} osztromok.com
    — built with <a href="https://fastapi.tiangolo.com">FastAPI</a>
  </footer>

  {# Extra scripts at the bottom of the page #}
  {% block extra_scripts %}{% endblock %}

</body>
</html>
subjects in the nav: the base template loops over subjects to build the nav links. For now, we pass subjects explicitly in every TemplateResponse context. In Chapter 5 we will centralise this with a shared context helper so it only needs to be written in one place.

5 Home template — app/templates/home.html

The home template extends the base, overrides the title and content blocks, and loops over the subjects list from the context to render a card for each one.

app/templates/home.html html + jinja2
{% extends "base.html" %}

{% block title %}osztromok.com — Home{% endblock %}

{% block content %}

  {# ── Hero ─────────────────────────────────────────────────────── #}
  <div class="home-hero">
    <h1>Welcome to <span>osztromok.com</span></h1>
    <p>A personal space for language learning, programming, and projects.</p>
  </div>

  {# ── Subject cards ────────────────────────────────────────────── #}
  <section class="subject-section">
    <div class="container">

      <p class="section-title">
        {{ subjects | length }} subjects
      </p>

      <div class="subject-grid">

        {% for subject in subjects %}
          <a href="/{{ subject.slug }}" class="subject-card">

            {# icon column holds an emoji or icon class — show if present #}
            {% if subject.icon %}
              <span class="card-icon">{{ subject.icon }}</span>
            {% endif %}

            <div class="card-name">{{ subject.name }}</div>

            {% if subject.description %}
              <div class="card-desc">
                {{ subject.description | truncate(90) }}
              </div>
            {% endif %}

          </a>
        {% else %}
          {# else runs when the for loop has zero iterations #}
          <p style="color:#6e7681">No subjects yet.</p>
        {% endfor %}

      </div>{# .subject-grid #}
    </div>{# .container #}
  </section>

{% endblock %}

6 Jinja2 syntax — the essentials

Here is a concise reference for the template syntax you will use in every chapter. All of these are already used in the templates above.

Output a variable
{{ subject.name }}
{{ subject.slug | upper }}
Double braces output a value. Dot notation accesses attributes. Pipe applies a filter.
For loop
{% for item in items %}
  {{ loop.index }} of {{ loop.length }}
{% else %}{% endfor %}
loop.index (1-based), loop.index0 (0-based), loop.first, loop.last are built-in.
Conditional
{% if x %}{% elif y %}{% else %}{% endif %}
Standard if/elif/else. None, empty string, 0, and [] are all falsy.
Template inheritance
{% extends "base.html" %}
{% block content %}{% endblock %}
extends must be the very first tag in a child template. Blocks override the parent's placeholder.
Static file URL
{{ url_for('static', path='css/main.css') }}
url_for() generates the correct URL for a named static mount. Works for /resources/ too.
Safe HTML output
{{ content | safe }}
Renders HTML stored in the database without escaping. Only use on trusted content.
Useful filters
{{ text | truncate(90) }}
{{ items | length }}
{{ name | capitalize }}
truncate adds "…" at the limit. length returns count. capitalize/upper/lower for case.
Comments
{# This is a comment — not in HTML output #}
Jinja2 comments never appear in rendered HTML. Use them freely.

7 Passing subjects to every template

The base template uses subjects in the nav loop. That means every route must include subjects in its context dict. For now, write a small helper function in app/database.py that returns the subject list — keeping the query in one place:

app/database.py — add at the bottom python
from sqlalchemy import select

def get_subjects(db: Session):
    """Return all subjects ordered by display_order.
    Call this in every route that renders a template so the nav is populated.
    In Chapter 5 we will replace this with a proper context processor."""
    from app.models import Subject  # local import avoids circular imports
    return (
        db.execute(select(Subject).order_by(Subject.display_order))
        .scalars()
        .all()
    )

Update app/main.py to use this helper — and here is the final home route that ties everything together:

app/main.py — home route, final version python
from app.database import get_db, get_subjects

@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,    # used in nav + page content
        },
    )

8 What the home page looks like

With uvicorn running (uvicorn app.main:app --reload) and your database connected, http://127.0.0.1:8000 will now render a real page like this:

http://127.0.0.1:8000/

Welcome to osztromok.com

A personal space for language learning, programming, and projects.

🇯🇵
Japan
Japanese lessons, kanji, culture
🇫🇷
French
Grammar, vocabulary, phrases
🇭🇺
Hungarian
Conversations with family in Hungary
🐍
Python
FastAPI, web development, projects

The data — subject names, descriptions, icons, and display order — all come from your live MySQL database. Update a row in the database and reload the page to see the change immediately. No PHP, no Apache config, no .htaccess.

✓ Chapter 3 Complete — Milestone reached

  • app/static/css/main.css — full site stylesheet with nav, cards, page content, footer, and responsive rules
  • app/templates/base.html — master layout with dynamic nav, block placeholders, url_for() static links
  • app/templates/home.html — extends base, renders live subject cards from the database
  • get_subjects() helper — centralised query reused across route handlers
  • Home route — queries DB, passes subjects to template, returns TemplateResponse
  • http://127.0.0.1:8000/ shows your real subjects as a card grid — the site is alive

Quick Reference — Chapter 3

Jinja2Templates(directory="app/templates") Create the templates engine instance
templates.TemplateResponse("x.html", {"request": request, ...}) Render a template with context — request is always required
{% extends "base.html" %} Inherit from the base template — must be the first tag
{% block name %}…{% endblock %} Define / override a named region in the layout
{{ url_for('static', path='css/main.css') }} Generate URL for a static file
{{ text | truncate(90) }} Truncate a string to 90 chars and add "…"
{{ content | safe }} Render HTML stored in DB without escaping
{% for x in items %}…{% else %}…{% endfor %} Loop with optional else block for empty lists