Jinja2
Jinja2 Templates
Build the base layout, site CSS, and a home page that renders live data from the database
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.
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 approach | Jinja2 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') }} |
{{ }}
expressions by default — so <script> in a database field becomes
<script> 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:
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.
/* ── 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.
<!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
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.
{% 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.
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:
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:
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:
Welcome to osztromok.com
A personal space for language learning, programming, and 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