Session 4: Static files and base templates
Phase 8 — Flask · Session 4 of 13
What we’re learning today
Two cleanups today. First, static files — CSS,
images, JS — go in a static/ folder, served
automatically by Flask. Second, base templates
— shared HTML (header, footer, navigation) lives in
one file; other templates {% extends %} it. By
the end your Flask site has clean separation: HTML
in templates, CSS in stylesheets, layout shared via
inheritance. Production shape.
You’ll need to remember from last time
- Templates —
templates/folder,render_template,{{ var }},{% for %},{% if %}. - Phase 7 CSS — selectors, properties, the box model, Flexbox.
- HTML structure — semantic tags, navbar patterns.
Part A: Static files
The static/ folder
Just like templates/, Flask expects static files
(CSS, images, JS) in a folder called static/
next to your app.py:
my_app/
├── app.py
├── templates/
│ └── home.html
└── static/
├── style.css
└── images/
└── logo.png
Files in static/ are served at URLs starting
with /static/. So:
static/style.css→http://127.0.0.1:5000/static/style.cssstatic/images/logo.png→http://127.0.0.1:5000/static/images/logo.png
Linking from a template
You could hardcode the URL:
<link rel="stylesheet" href="/static/style.css">
But the right way uses Flask’s url_for:
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
url_for('static', filename='...') generates the
correct URL for the static file. Why use it?
- Works no matter where the app is mounted.
- Survives deployment to subpaths.
- Standard practice.
It’s longer to type but worth the habit.
Try it
Create static/style.css:
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, Arial, sans-serif;
max-width: 700px;
margin: 40px auto;
padding: 20px;
background: #f4f1eb;
color: #333;
line-height: 1.6;
}
h1 { color: #2c3e50; }
nav a {
margin-right: 16px;
color: #3498db;
text-decoration: none;
}
nav a:hover {
text-decoration: underline;
}
Update templates/home.html:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<h1>{{ heading }}</h1>
<p>This page is styled.</p>
</body>
</html>
Save. Run. Reload. The page is styled.
Open DevTools → Network tab. Reload. You see two requests:
/(the HTML)/static/style.css(the CSS)
Both served by Flask. Two separate things — that’s how the web works.
Adding an image
Drop an image into static/images/logo.png (any
PNG you like).
In your template:
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">
Same pattern — url_for('static', filename='path/ to/file').
What goes in static?
- CSS files.
- JavaScript files (for any client-side interactivity).
- Images, fonts, downloadable files.
Anything that’s served as-is (not generated by Python).
Checkpoint: Your Flask app serves at least
one CSS file from static/ and uses url_for to
link it. This is the natural stop point if class
is cut short.
Part B: Base templates with inheritance
The problem
Right now, every template has the full
<!DOCTYPE html>...</html> boilerplate. Add a new
page → copy the boilerplate. Change the navbar →
edit every page. Mess.
The solution — {% extends %}
Make a base template that has the shared structure. Each child template extends it and fills in blocks.
templates/base.html:
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/projects">Projects</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2026 My Site</p>
</footer>
</body>
</html>
Notice the blocks — {% block title %} and
{% block content %}. Child templates fill these
in.
templates/home.html:
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>Welcome!</h1>
<p>This is my Flask site.</p>
<p>Navigation works because the navbar lives in <code>base.html</code>.</p>
{% endblock %}
templates/about.html:
{% extends "base.html" %}
{% block title %}About{% endblock %}
{% block content %}
<h1>About</h1>
<p>I'm learning Flask. This is page 2.</p>
{% endblock %}
Update app.py:
@app.route("/")
def home():
return render_template("home.html")
@app.route("/about")
def about():
return render_template("about.html")
@app.route("/projects")
def projects():
return render_template("projects.html") # add this template too
Save. Run. Visit each page. Same navbar, same footer, different content. Each page is just the main content — base.html provides the chrome.
If you change the navbar in base.html, every page
updates. One source of truth. Real production
practice.
How it works
{% extends "base.html" %}— child says “I’m based on this template.”{% block content %}...{% endblock %}— child replaces the block with new content.- The base’s other content (navbar, footer) stays the same.
Default content in a block:
{% block title %}My Site{% endblock %}
If a child doesn’t define {% block title %}, the
default (“My Site”) is used. Otherwise the child’s
content replaces it.
Add a third page
Make templates/projects.html:
{% extends "base.html" %}
{% block title %}Projects{% endblock %}
{% block content %}
<h1>My projects</h1>
<ul>
<li>Pong (Pygame)</li>
<li>Todo app (customtkinter)</li>
<li>This Flask site</li>
</ul>
{% endblock %}
Save. Visit /projects (after adding the route).
Same navbar, fresh content. Five lines of
unique HTML; everything else inherited.
Stretch — url_for for pages too
Hardcoded route URLs (<a href="/about">) get
fragile if you renamed routes. url_for works for
your own routes too:
<a href="{{ url_for('about') }}">About</a>
url_for('about') calls the function named
about (matching your def about():) and returns
its URL.
If you renamed def about(): to
def about_me():, you’d just update one place;
all url_for('about_me') calls update
automatically.
Use url_for for your routes too. Real production
discipline.
Stretch — multiple blocks
<!-- base.html -->
<head>
{% block extra_head %}{% endblock %}
</head>
Child can inject head content (extra CSS, scripts):
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='extra.css') }}">
{% endblock %}
Multiple blocks let children customize specific parts.
Stretch — {% include %}
For reusable chunks (a card, a sidebar) that aren’t the whole page:
{# templates/_card.html — partial #}
<div class="card">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
{# templates/index.html #}
{% include "_card.html" %}
{% include "_card.html" %}
{% include "_card.html" %}
Underscored filenames are convention for “partials” — pieces of templates, not full pages.
Extension — block.super
Append to a parent block instead of replacing:
{% block title %}Special Page — {{ super() }}{% endblock %}
Combines parent and child content. Useful for title prefixes and accumulating CSS/JS.
Wrap-up
Before we leave, share with the room:
- For everyone — show your site. How many pages share the same base?
- Did changing one thing in
base.htmland seeing every page update feel powerful? - For the kids who used
url_forfor routes — does the indirection make sense?
Today you learned:
static/folder — Flask’s convention for CSS, JS, images.url_for('static', filename='...')— generate static URLs.{% extends "base.html" %}— child template inherits from a base.{% block name %}{% endblock %}— fillable spot in a base; child overrides.- One source of truth — base layout in one file, content per page.
{% include "..." %}— reusable partials.url_for('route_name')— generate URLs from route function names.
Your sites now have the shape of a real production site: clean separation between shared chrome and per-page content. Real Django sites, real Rails sites, real PHP sites — all work the same way.
Next week: forms — accept user input via POST requests. The first time the user changes data on the server.
If you missed this session
Open Thonny.
-
Create a
static/folder. Put a CSS file in it. -
Link from your templates with
url_for. -
Create
templates/base.htmlwith{% block %}tags. -
Refactor at least 2 pages to extend the base.
About 30-45 minutes. By the end your site should share a base layout.
Stretch and extension ideas
url_forfor routes too — clean indirection.- Multiple blocks — content, title, extra_head, etc.
{% include %}partials — reusable components.block.super()— append to parent block.- CSS variables in your stylesheet (Phase 7 callback).
- Flexbox layouts in your CSS (Phase 7 callback).
- Mobile-responsive design with
@mediaqueries. - Error templates —
templates/404.html, registered with@app.errorhandler(404). - Favicon — drop
static/favicon.icoand add<link rel="icon">in base.
What’s next
Next week: forms and POST requests. Users submit forms; your Python processes them and responds. The first time data flows from the browser to your server.