The idea
Building HTML with f-strings works for a heading. For a real page (a table of orders, a navigation bar, a footer on every page), Python strings full of HTML become unreadable and dangerous. Templates solve this: HTML files with placeholders, filled in by Flask using the Jinja2 template engine.
The split is clean: Python decides what data to show; the template decides how it looks.
Your first template
Templates live in a folder named templates/ next to your app:
shop/
├── app.py
└── templates/
└── product.html
templates/product.html (this module includes it):
<h1>{{ name }}</h1>
<p>Price: Rp {{ "{:,}".format(price) }}</p>
app.py:
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/products/<int:product_id>")
def product(product_id):
return render_template("product.html", name="Ceramic Mug", price=55000)
client = app.test_client()
print(client.get("/products/6").get_data(as_text=True))
<h1>Ceramic Mug</h1>
<p>Price: Rp 55,000</p>
render_template("product.html", name=..., price=...)loads the file fromtemplates/and fills it in.{{ expression }}prints a value. Inside, Jinja understands variables, attributes (order.total), keys (order["total"]) and simple expressions.
Jinja syntax in one table
| Syntax | Does | Example | |||
|---|---|---|---|---|---|
{{ ... }} | print a value | {{ customer.name }} | |||
{% ... %} | logic: loops, ifs, blocks | {% for o in orders %} ... {% endfor %} | |||
{# ... #} | a comment, not in the output | {# shown to developers only #} | |||
| `\ | ` filter | transform a value | `{{ name\ | upper }}, {{ items\ | length }}` |
Loops and conditions
For quick experiments (and on this site), render_template_string takes the template as a string instead of a file. The syntax is identical:
from flask import Flask, render_template_string
app = Flask(__name__)
ORDERS_PAGE = """
<h1>Orders ({{ orders|length }})</h1>
{% if orders %}
<ul>
{% for o in orders %}
<li>#{{ o.id }}: {{ o.customer }}, Rp {{ "{:,}".format(o.total) }}{% if o.total > 500000 %} <b>big</b>{% endif %}</li>
{% endfor %}
</ul>
{% else %}
<p>No orders yet.</p>
{% endif %}
"""
@app.route("/orders")
def orders():
data = [
{"id": 38, "customer": "Dimas Pratama", "total": 90000},
{"id": 33, "customer": "Kartika Rahma", "total": 735000},
]
return render_template_string(ORDERS_PAGE, orders=data)
client = app.test_client()
print(client.get("/orders").get_data(as_text=True))
<h1>Orders (2)</h1>
<ul>
<li>#38: Dimas Pratama, Rp 90,000</li>
<li>#33: Kartika Rahma, Rp 735,000 <b>big</b></li>
</ul>
The blank lines come from the {% %} tags; browsers ignore them. (app.jinja_env.trim_blocks = True removes them if you care.)
o.id works for both object attributes and dict keys: Jinja tries both.
Filters
Templates often need a value tweaked just for display: a name in capitals, a list's length, a fallback when something is missing, a long note cut short. Jinja's filters do that inside the template. You write the value, a |, and the filter's name:
from flask import Flask, render_template_string
app = Flask(__name__)
with app.app_context():
print(render_template_string("{{ name|upper }} | {{ name|title }} | {{ items|length }} | {{ missing|default('n/a') }}",
name="ayu lestari", items=[1, 2, 3]))
print(render_template_string("{{ price|round(1) }} | {{ tags|join(', ') }} | {{ note|truncate(12) }}",
price=12.345, tags=["coffee", "gift"], note="Fragile, handle with care"))
AYU LESTARI | Ayu Lestari | 3 | n/a
12.3 | coffee, gift | Fragile,...
Auto-escaping: safety for free
Here's a question that matters more than it looks: what happens if a customer types HTML, or even a script, into their name, and your template shows that name on a page?
from flask import Flask, render_template_string
app = Flask(__name__)
with app.app_context():
print(render_template_string("<p>Hello {{ name }}</p>", name="<script>alert('hi')</script>"))
<p>Hello <script>alert('hi')</script></p>
Jinja escaped the < and >, so the browser shows the text instead of running the script. Without escaping, anyone who can type into a form (a review, a name, an address) could inject JavaScript into your pages; this is called cross-site scripting (XSS). Templates rendered by Flask escape by default. Building HTML with f-strings doesn't, which is the main reason to stop doing it.
Put data into HTML with {{ }} in templates, never with string formatting. Escaping is automatic, so user input can't become code.
Template inheritance: one layout, many pages
Every page shares a header and footer. Write them once in a base template with blocks that pages fill in:
templates/base.html (included in this module):
<!doctype html>
<html>
<head><title>{% block title %}Toko Kita{% endblock %}</title></head>
<body>
<nav><a href="/">Home</a> · <a href="/about">About</a></nav>
<main>{% block content %}{% endblock %}</main>
<footer>© Toko Kita</footer>
</body>
</html>
templates/about.html:
{% extends "base.html" %}
{% block title %}About · Toko Kita{% endblock %}
{% block content %}<h1>About us</h1><p>Coffee and desk gear since 2023.</p>{% endblock %}
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/about")
def about():
return render_template("about.html")
html = app.test_client().get("/about").get_data(as_text=True)
print(html)
<!doctype html>
<html>
<head><title>About · Toko Kita</title></head>
<body>
<nav><a href="/">Home</a> · <a href="/about">About</a></nav>
<main><h1>About us</h1><p>Coffee and desk gear since 2023.</p></main>
<footer>© Toko Kita</footer>
</body>
</html>
{% extends %} says "start from base.html"; each {% block %} replaces the base's block of the same name. Change the navigation once in base.html, and every page updates.
Keep logic in Python
Jinja can do maths and call methods, but resist it. Compute totals, filter lists and format money in the view (or a helper function), and pass ready-to-show values to the template. A template full of logic is as hard to test as a 300-line function.
Why this matters for clean code
Templates separate presentation from logic, the web version of the "data prep → display" split you've used in every track. Views become short (load data, call render_template), templates are readable HTML a designer could edit, and auto-escaping removes a whole class of security bugs.
Recap
- Templates live in
templates/;render_template("file.html", key=value)fills them in. {{ value }}prints;{% for %}/{% if %}add logic;{# #}comments;|filtertransforms.render_template_stringdoes the same with a string.- Values are auto-escaped, preventing XSS; never build HTML from user input with f-strings.
{% extends "base.html" %}and{% block name %}share one layout across pages.- Keep calculations in Python; templates only display.