Index Ai Academy
Phase 8 · APIs · Web apps

Web pages with Flask

Routes, Jinja templates, a shared layout, links and error pages, tested with the test client.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

So far you've built APIs that answer with JSON. Many applications also need pages people read in a browser: a dashboard, a blog, an admin screen, the front of a small product. Flask is Python's most popular small web framework, and its template engine, Jinja, is used far beyond Flask, from Ansible to static-site generators. The framework lesson showed how such a framework works inside; this lesson uses one to build real pages, safely.

What you'll be able to do

  • Build a Flask app with routes, URL parameters and converters
  • Generate links with url_for instead of writing addresses by hand
  • Render pages from Jinja templates, with inheritance, loops and automatic escaping
  • Return the right status codes, with custom error pages, and test pages with the test client

What it covers

  1. A Flask app
  2. URL parameters and converters
  3. Templates
  4. One layout for every page: inheritance
  5. Error pages and testing
  6. Worked example: a guessing game in the URL
  7. A portfolio site generator

A Flask app

A Flask app is an object that maps URL paths to Python functions. Each function, a view, returns what the browser should receive:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "<h1>Trail Notes</h1><p>Stories and gear tips from the practice shop.</p>"

@app.route("/about")
def about():
    return "<h1>About</h1>"

client = app.test_client()
for path in ["/", "/about", "/missing"]:
    reply = client.get(path)
    print(path, reply.status_code, reply.get_data(as_text=True)[:40])
It runs right here in your browser, with nothing to install.

@app.route("/") registers home for the path /: that's the decorator idea from the decorators lesson, used to fill a table of routes. Returning a string sends it as an HTML page with status 200. An unknown path gets a 404, which Flask handles for you.

On your own computer you'd start a development server with flask --app app run and open http://127.0.0.1:5000 in a browser. Here, the test client sends requests to the app directly, with no network: exactly how you'll test web apps in real projects too.

URL parameters and converters

Parts of the path can be variables. <name> captures a piece of the URL and passes it to the view; a converter like <int:...> also checks and converts it:

from flask import Flask, abort

app = Flask(__name__)
TRAILS = {"fern-hollow": "Fern Hollow loop, 6 km", "red-mesa": "Red Mesa ridge, 11 km"}

@app.route("/trails/<slug>")
def trail(slug):
    if slug not in TRAILS:
        abort(404)
    return f"<h1>{TRAILS[slug]}</h1>"

@app.route("/orders/<int:order_id>")
def order(order_id):
    return f"order number {order_id}, doubled is {order_id * 2}"

client = app.test_client()
for path in ["/trails/red-mesa", "/trails/nowhere", "/orders/50231", "/orders/abc"]:
    reply = client.get(path)
    print(path, reply.status_code, reply.get_data(as_text=True)[:50])

/orders/abc doesn't even reach the view: the int converter refuses it, so the app answers 404 by itself. Flask also has float, uuid and path converters; path accepts a value with slashes in it, such as a folder name inside a longer address. abort(404) stops a view and sends that status. Use it whenever the thing asked for doesn't exist, so browsers, search engines and other programs see the truth instead of an "OK" page that says "not found".

Notice the trail view builds HTML with an f-string. That's fine for text you control, but dangerous for anything a user typed, as the HTML lesson showed. Templates fix that.

Quick check
What does this print?
from flask import Flask
app = Flask("demo")
@app.route("/n/<int:n>")
def double(n):
    return str(n * 2)
c = app.test_client()
print(c.get("/n/21").get_data(as_text=True), c.get("/n/x").status_code)
  1. 42 404
  2. 42 200
  3. 2121 404
  4. 42 500
Show the answer

42 404

The int converter turns "21" into the number 21, so the view returns 42. "x" isn't an int, so the route doesn't match: 404.

Templates

A template is an HTML file with gaps that Jinja fills in. {{ ... }} prints a value, and {% ... %} runs logic like loops and ifs. Most importantly, Jinja escapes every value it prints in an HTML template, so a name with <script> in it shows as text instead of running.

from flask import Flask, render_template
from jinja2 import DictLoader

app = Flask(__name__)
app.jinja_loader = DictLoader({
    "trails.html": """
<h1>{{ title }}</h1>
{% if trails %}
<ul>
  {% for t in trails %}
  <li>{{ t.name }}: {{ t.km }} km{% if t.km > 10 %} <strong>(long)</strong>{% endif %}</li>
  {% endfor %}
</ul>
{% else %}
<p>No trails yet.</p>
{% endif %}
""",
})

@app.route("/")
def trails():
    data = [{"name": "Fern Hollow", "km": 6}, {"name": "Red Mesa <ridge>", "km": 11}]
    return render_template("trails.html", title="Trails & tips", trails=data)

print(app.test_client().get("/").get_data(as_text=True))

In a real project, templates live as files in a templates/ folder next to the app, and render_template("trails.html", ...) finds them there. Here a DictLoader holds them in a dictionary instead, which behaves the same way.

Look at the output: "Red Mesa <ridge>" came out as Red Mesa &lt;ridge&gt; and "&" as &amp;. You didn't call html.escape; the template did it for you. That's the main reason to use templates for HTML.

One layout for every page: inheritance

Every page of a site shares the same frame: the head, the navigation, the footer. Template inheritance writes that frame once in a base template with named blocks, and each page fills in the blocks it needs:

from flask import Flask, render_template, url_for
from jinja2 import DictLoader

app = Flask(__name__)
app.jinja_loader = DictLoader({
    "base.html": """<!doctype html>
<html lang="en"><head><title>{% block title %}Trail Notes{% endblock %}</title></head>
<body>
<nav><a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('trail', slug='red-mesa') }}">Red Mesa</a></nav>
<main>{% block content %}{% endblock %}</main>
</body></html>""",
    "home.html": """{% extends "base.html" %}
{% block content %}<h1>Welcome</h1>{% endblock %}""",
    "trail.html": """{% extends "base.html" %}
{% block title %}{{ name }} | Trail Notes{% endblock %}
{% block content %}<h1>{{ name }}</h1>{% endblock %}""",
})

@app.route("/")
def home():
    return render_template("home.html")

@app.route("/trails/<slug>")
def trail(slug):
    return render_template("trail.html", name=slug.replace("-", " ").title())

with app.test_request_context():
    print(url_for("trail", slug="fern-hollow"), url_for("home"))
print(app.test_client().get("/trails/red-mesa").get_data(as_text=True))

{% extends "base.html" %} makes a page start from the base, and each {% block %} replaces the base's version. url_for("trail", slug="red-mesa") builds the address from the view's name and its parameters. If the route changes from /trails/<slug> to /t/<slug>, every link updates by itself, instead of breaking in twenty places.

Try it now

Flask does the plumbing; your code is the view and the template.

Error pages and testing

Flask's default 404 page is plain. @app.errorhandler(404) replaces it with your own, but remember to return the status code too, or the browser will think the missing page exists:

@app.errorhandler(404)
def not_found(error):
    return render_template("404.html"), 404

A view can return a tuple of (body, status) like this one, or (body, status, headers).

Testing pages works like testing any function: send a request with the test client, then check the status and the content. Parsing the HTML with Beautiful Soup, as the scraping lesson did, makes checks precise: "the page has three post links" rather than "the text contains the word post". Test the edges as well: an unknown slug, a name with < and & in it, a page with nothing to list.

A Flask project on your own computer

On your own machine, a small Flask project is a folder like this:

trail-notes/
  app.py              # the Flask app and its views
  templates/          # base.html, home.html, post.html, 404.html
  static/             # style.css, images, scripts
  tests/test_pages.py # test-client tests, run with pytest

flask --app app run --debug starts a development server that reloads when you save a file and shows a detailed error page when a view crashes. That error page can run code, so never turn on debug mode on a public server; production apps run behind a real server such as Gunicorn, which a later lesson covers. Stylesheets and images in static/ get their addresses from url_for("static", filename="style.css"), like any other link.

Many tutorials end app.py with if __name__ == "__main__": app.run(debug=True). Inside the academy there's no server to start, so app.run() shows your app's home page under the output instead, through the test client. Your code doesn't need to change:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "<h1>Trail Notes</h1><p>Stories and gear tips from the practice shop.</p>"

if __name__ == "__main__":
    app.run(debug=True)
Worked example

Worked example: a guessing game in the URL

A tiny game: guess the height of a summit by visiting /guess/<number>. Each page says higher or lower, and links to the next sensible guess.

from flask import Flask, render_template, url_for
from jinja2 import DictLoader

SUMMIT_M = 2741

app = Flask(__name__)
app.jinja_loader = DictLoader({
    "guess.html": """<h1>{{ message }}</h1>
{% if hint %}<p><a href="{{ url_for('guess', meters=hint) }}">Try {{ hint }} m</a></p>{% endif %}
<p><a href="{{ url_for('start') }}">Start again</a></p>""",
})

@app.route("/")
def start():
    return render_template("guess.html", message="How high is Red Mesa? Guess with /guess/<meters>", hint=1500)

@app.route("/guess/<int:meters>")
def guess(meters):
    if meters == SUMMIT_M:
        return render_template("guess.html", message=f"{meters} m is right!", hint=None)
    step = max(1, abs(SUMMIT_M - meters) // 2)
    nxt = meters + step if meters < SUMMIT_M else meters - step
    word = "higher" if meters < SUMMIT_M else "lower"
    return render_template("guess.html", message=f"{meters} m: go {word}", hint=nxt)

client = app.test_client()
path = "/guess/1500"
for _ in range(12):
    page = client.get(path).get_data(as_text=True)
    first_line = page.strip().splitlines()[0]
    print(path, "->", first_line.replace("<h1>", "").replace("</h1>", ""))
    if "right" in page:
        break
    path = page.split('href="')[1].split('"')[0]
In the lesson you can change this and run it.

The state of the game lives entirely in the URL, so every page can be bookmarked or shared. The view builds the "next guess" link with url_for, halving the distance each time. The loop at the bottom plays the game by following the links, the way a browser user would, until the page says it's right. It's also a test: the game always finishes within a dozen clicks.

Portfolio brief

A portfolio site generator

Every developer needs a portfolio site, and a good way to show you can build software is to build the tool that makes it. Write a static site generator: it reads your details and projects from YAML, renders them with Jinja templates into plain HTML files, and checks its own output.

The templates share a base layout with template inheritance, as in this lesson. The generator writes a home page, one page per project and a page per tag, all linked together. Because a broken link on a portfolio looks careless, the last step parses every page it wrote and checks that each internal link points at a page that exists.

It must

  • Read the site's content from YAML with yaml.safe_load (your name, a short bio and at least three projects with titles, summaries and tags)
  • Render pages with Jinja templates that extend one base template, with autoescaping on
  • Write index.html, one page per project and one page per tag into a site/ folder, with links between them
  • Parse every generated page (with html.parser or Beautiful Soup) and report any internal link that doesn't match a written file
  • Show the home page with preview() from the preview module

Go further

  • Turn each project's description from Markdown into HTML
  • Add a page listing projects by year, newest first
  • Fail loudly, with the file and field named, when the YAML is missing a required field
import yaml
from jinja2 import DictLoader, Environment, select_autoescape

CONTENT = yaml.safe_load("""
name: Sam Rivera
bio: I build small, useful tools in Python.
projects:
  - title: Pack Train
    slug: pack-train
    summary: A grid arcade game with a saved best score.
    tags: [python, games]
""")

TEMPLATES = {
    "base.html": "<!doctype html><title>{% block title %}{% endblock %}</title><main>{% block body %}{% endblock %}</main>",
    "index.html": "{% extends 'base.html' %}{% block title %}{{ name }}{% endblock %}{% block body %}<h1>{{ name }}</h1>{% endblock %}",
}
env = Environment(loader=DictLoader(TEMPLATES), autoescape=select_autoescape())
print(env.get_template("index.html").render(**CONTENT))
# your code: more content, every page, the site/ folder and the link check
In the lesson, you build it here and the tutor can review it.

Practice what you just read

  • 7 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why build links with url_for('post', slug=s) instead of writing '/posts/' + s?

  1. If the route's address changes, every link updates by itself
  2. It makes pages load faster
  3. Browsers can't follow handwritten links
  4. It escapes the slug as HTML

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson