Routing and templating are the two pillars of every Flask application. Routes map incoming HTTP requests to Python functions, and templates turn raw data into HTML that browsers can render. If you understand these two systems deeply, you can build anything from a single-page prototype to a production web application with dozens of endpoints.
Flask’s routing system is powered by Werkzeug, a battle-tested WSGI toolkit, while its templating engine is Jinja2, one of the most capable template engines in the Python ecosystem. Together they give you explicit control over every URL, every HTTP method, and every piece of markup your application serves.
This tutorial assumes you have Flask installed and a basic understanding of Python. We will start with route fundamentals, work through request and response handling, take a deep dive into Jinja2, and finish by building a complete Task Manager application that ties everything together.
A route in Flask is a mapping between a URL pattern and a Python function. You declare routes with the @app.route() decorator.
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Home Page'
@app.route('/about')
def about():
return 'About Page'
if __name__ == '__main__':
app.run(debug=True)
When Python loads your module, each @app.route() decorator calls app.add_url_rule() under the hood. This registers the URL pattern, the endpoint name (defaults to the function name), and the view function in Flask’s URL map. You can inspect registered routes at any time:
# Print all registered routes
for rule in app.url_map.iter_rules():
print(f'{rule.endpoint}: {rule.rule} [{", ".join(rule.methods)}]')
Flask distinguishes between routes with and without trailing slashes, and the behavior is intentional:
# With trailing slash - acts like a directory
# /projects -> redirects to /projects/
# /projects/ -> 200 OK
@app.route('/projects/')
def projects():
return 'Projects List'
# Without trailing slash - acts like a file
# /about -> 200 OK
# /about/ -> 404 Not Found
@app.route('/about')
def about():
return 'About Page'
When a route is defined with a trailing slash and a user visits the URL without it, Flask issues a 301 redirect to the version with the slash. When a route is defined without a trailing slash and a user visits with one, Flask returns a 404. This is consistent with how web servers handle directories and files. My recommendation: use trailing slashes for collection endpoints and no trailing slash for individual resource endpoints.
You can also register routes explicitly using add_url_rule(). This is useful when the view function is defined elsewhere or generated dynamically:
def health_check():
return 'OK', 200
app.add_url_rule('/health', endpoint='health', view_func=health_check)
By default, a route only responds to GET requests. To handle other HTTP methods, pass them explicitly via the methods parameter.
from flask import Flask, request, jsonify
app = Flask(__name__)
tasks = []
# GET - Retrieve all tasks
@app.route('/api/tasks', methods=['GET'])
def get_tasks():
return jsonify(tasks)
# POST - Create a new task
@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.get_json()
task = {
'id': len(tasks) + 1,
'title': data['title'],
'done': False
}
tasks.append(task)
return jsonify(task), 201
# PUT - Update an existing task
@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
task = next((t for t in tasks if t['id'] == task_id), None)
if task is None:
return jsonify({'error': 'Task not found'}), 404
data = request.get_json()
task['title'] = data.get('title', task['title'])
task['done'] = data.get('done', task['done'])
return jsonify(task)
# DELETE - Remove a task
@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
global tasks
tasks = [t for t in tasks if t['id'] != task_id]
return '', 204
Sometimes it makes sense to handle multiple methods in a single view function. Use request.method to branch logic:
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
# authenticate user
return redirect(url_for('dashboard'))
# GET request - show login form
return render_template('login.html')
URL variables let you capture dynamic segments from the URL and pass them as arguments to your view function. Flask supports several built-in converters.
# Matches any text without slashes
@app.route('/user/<username>')
def user_profile(username):
return f'Profile: {username}'
# /user/folau -> username = "folau"
# /user/jane -> username = "jane"
# Only matches integers, rejects non-numeric values with 404
@app.route('/post/<int:post_id>')
def show_post(post_id):
return f'Post #{post_id}'
# /post/42 -> post_id = 42 (200 OK)
# /post/abc -> 404 Not Found
@app.route('/price/<float:amount>')
def show_price(amount):
return f'Price: ${amount:.2f}'
# /price/19.99 -> amount = 19.99
# /price/100 -> 404 (integers don't match float)
# Matches text including slashes - useful for file paths
@app.route('/files/<path:filepath>')
def serve_file(filepath):
return f'File: {filepath}'
# /files/docs/readme.txt -> filepath = "docs/readme.txt"
import uuid
@app.route('/transaction/<uuid:transaction_id>')
def show_transaction(transaction_id):
return f'Transaction: {transaction_id}'
# /transaction/a8098c1a-f86e-11da-bd1a-00112444be1e -> matches
@app.route('/blog/<int:year>/<int:month>/<slug>')
def blog_post(year, month, slug):
return f'Post: {slug} ({year}-{month:02d})'
# /blog/2026/02/flask-routes -> year=2026, month=2, slug="flask-routes"
Hard-coding URLs in your application is fragile. If you rename a route or change a URL pattern, every hard-coded reference breaks. The url_for() function solves this by generating URLs from endpoint names.
from flask import Flask, url_for
app = Flask(__name__)
@app.route('/')
def index():
return 'Home'
@app.route('/user/<username>')
def user_profile(username):
return f'Profile: {username}'
@app.route('/post/<int:post_id>')
def show_post(post_id):
return f'Post #{post_id}'
with app.test_request_context():
print(url_for('index')) # /
print(url_for('user_profile', username='folau')) # /user/folau
print(url_for('show_post', post_id=42)) # /post/42
print(url_for('show_post', post_id=42, highlight='true'))
# /post/42?highlight=true (extra kwargs become query params)
print(url_for('static', filename='css/style.css'))
# /static/css/style.css
There are three reasons to always use url_for() instead of hard-coded paths:
url_for() correctly prefixes the blueprint’s URL prefix without you having to remember it.<!-- In Jinja2 templates -->
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('user_profile', username='folau') }}">My Profile</a>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
As your application grows, keeping every route in a single file becomes unmanageable. Blueprints let you organize routes into logical modules, each with its own URL prefix, templates, and static files.
# blueprints/auth.py
from flask import Blueprint, render_template, request, redirect, url_for
auth_bp = Blueprint('auth', __name__, template_folder='templates')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# handle login
return redirect(url_for('auth.dashboard'))
return render_template('auth/login.html')
@auth_bp.route('/logout')
def logout():
# handle logout
return redirect(url_for('auth.login'))
@auth_bp.route('/dashboard')
def dashboard():
return render_template('auth/dashboard.html')
# app.py from flask import Flask from blueprints.auth import auth_bp from blueprints.tasks import tasks_bp from blueprints.api import api_bp app = Flask(__name__) # Register with URL prefixes app.register_blueprint(auth_bp, url_prefix='/auth') app.register_blueprint(tasks_bp, url_prefix='/tasks') app.register_blueprint(api_bp, url_prefix='/api/v1') # Routes become: # /auth/login, /auth/logout, /auth/dashboard # /tasks/, /tasks/create, /tasks/1/edit # /api/v1/tasks, /api/v1/tasks/1
# Reference a blueprint endpoint with 'blueprint_name.endpoint'
url_for('auth.login') # /auth/login
url_for('tasks.create') # /tasks/create
url_for('api.get_tasks') # /api/v1/tasks
# Within the same blueprint, use a dot prefix for the current blueprint
url_for('.login') # resolves to current blueprint's login
myapp/
├── app.py
├── config.py
├── blueprints/
│ ├── __init__.py
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ └── templates/
│ │ └── auth/
│ │ ├── login.html
│ │ └── register.html
│ └── tasks/
│ ├── __init__.py
│ ├── routes.py
│ └── templates/
│ └── tasks/
│ ├── list.html
│ └── detail.html
├── templates/
│ └── base.html
└── static/
├── css/
├── js/
└── img/
Flask provides the request object as a context-local proxy. It gives you access to everything the client sent: query parameters, form data, JSON payloads, files, headers, and cookies.
from flask import request
# URL: /search?q=flask&page=2&limit=20
@app.route('/search')
def search():
query = request.args.get('q', '') # 'flask'
page = request.args.get('page', 1, type=int) # 2
limit = request.args.get('limit', 10, type=int) # 20
results = perform_search(query, page, limit)
return render_template('search.html', results=results, query=query)
# Handles application/x-www-form-urlencoded and multipart/form-data
@app.route('/register', methods=['POST'])
def register():
username = request.form['username'] # raises 400 if missing
email = request.form.get('email', '') # returns '' if missing
password = request.form['password']
# validate and create user
return redirect(url_for('login'))
# Handles application/json content type
@app.route('/api/tasks', methods=['POST'])
def create_task():
data = request.get_json() # returns None if parsing fails
if data is None:
return jsonify({'error': 'Invalid JSON'}), 400
title = data.get('title')
if not title:
return jsonify({'error': 'Title is required'}), 400
task = {'id': 1, 'title': title, 'done': False}
return jsonify(task), 201
import os
from werkzeug.utils import secure_filename
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB limit
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return 'No file part', 400
file = request.files['file']
if file.filename == '':
return 'No selected file', 400
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return f'File {filename} uploaded successfully', 201
return 'File type not allowed', 400
@app.route('/debug')
def debug_request():
info = {
'method': request.method, # GET, POST, etc.
'url': request.url, # full URL
'path': request.path, # /debug
'host': request.host, # localhost:5000
'content_type': request.content_type,
'user_agent': str(request.user_agent),
'remote_addr': request.remote_addr, # client IP
'is_json': request.is_json,
'cookies': dict(request.cookies),
'headers': dict(request.headers),
}
return jsonify(info)
Flask gives you multiple ways to construct HTTP responses, from simple strings to fully customized response objects.
@app.route('/')
def index():
return 'Hello, World!' # 200 OK, text/html
# (body, status_code)
@app.route('/not-found')
def not_found():
return 'Resource not found', 404
# (body, status_code, headers)
@app.route('/custom')
def custom():
return 'Custom Response', 200, {'X-Custom-Header': 'flask-tutorial'}
# (body, headers)
@app.route('/no-cache')
def no_cache():
return 'Fresh data', {'Cache-Control': 'no-store'}
from flask import make_response
@app.route('/cookie')
def set_cookie():
resp = make_response('Cookie has been set')
resp.set_cookie('username', 'folau', max_age=3600)
resp.headers['X-Custom'] = 'value'
return resp
@app.route('/download')
def download_csv():
csv_data = 'name,email\nFolau,folau@example.com\n'
resp = make_response(csv_data)
resp.headers['Content-Type'] = 'text/csv'
resp.headers['Content-Disposition'] = 'attachment; filename=users.csv'
return resp
from flask import jsonify
@app.route('/api/status')
def api_status():
return jsonify({
'status': 'healthy',
'version': '1.0.0',
'uptime': 99.9
})
# Returns application/json with proper Content-Type header
# With status code
@app.route('/api/error')
def api_error():
return jsonify({'error': 'Something went wrong'}), 500
from flask import redirect, url_for, abort
@app.route('/old-page')
def old_page():
return redirect(url_for('new_page')) # 302 redirect
@app.route('/old-permanent')
def old_permanent():
return redirect(url_for('new_page'), code=301) # 301 permanent
@app.route('/admin')
def admin():
if not current_user.is_admin:
abort(403) # Forbidden - raises HTTPException
return render_template('admin.html')
# Custom error handlers
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('500.html'), 500
Jinja2 is Flask’s default template engine. It provides template inheritance, control structures, filters, macros, and more. Templates live in the templates/ directory by default.
Template inheritance is Jinja2’s most powerful feature. You define a base template with blocks that child templates can override.
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My App{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<nav>
{% block navbar %}
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('tasks.list') }}">Tasks</a>
<a href="{{ url_for('auth.login') }}">Login</a>
{% endblock %}
</nav>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
{% block footer %}
<p>© 2026 My App</p>
{% endblock %}
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
<!-- templates/tasks/list.html -->
{% extends "base.html" %}
{% block title %}Tasks - My App{% endblock %}
{% block content %}
<h1>Task List</h1>
{% if tasks %}
<ul>
{% for task in tasks %}
<li>
<a href="{{ url_for('tasks.detail', task_id=task.id) }}">
{{ task.title }}
</a>
{% if task.done %}
<span class="badge">Done</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No tasks yet. <a href="{{ url_for('tasks.create') }}">Create one</a>.</p>
{% endif %}
{% endblock %}
<!-- Simple variable output -->
<h1>{{ user.name }}</h1>
<p>Email: {{ user.email }}</p>
<!-- Expressions -->
<p>Total: ${{ price * quantity }}</p>
<p>Full Name: {{ first_name ~ ' ' ~ last_name }}</p>
<!-- Default values -->
<p>Welcome, {{ username | default('Guest') }}</p>
<!-- Accessing dictionaries and lists -->
<p>{{ config['DEBUG'] }}</p>
<p>{{ items[0] }}</p>
<!-- Ternary-style conditional expression -->
<p class="{{ 'active' if is_active else 'inactive' }}">Status</p>
<!-- If / Elif / Else -->
{% if user.role == 'admin' %}
<a href="/admin">Admin Panel</a>
{% elif user.role == 'editor' %}
<a href="/editor">Editor Panel</a>
{% else %}
<a href="/profile">My Profile</a>
{% endif %}
<!-- For Loop -->
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'even' if loop.index is even else 'odd' }}">
<td>{{ loop.index }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
{% else %}
<tr><td colspan="3">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
<!-- Loop Variable Properties -->
{# loop.index - current iteration (1-indexed) #}
{# loop.index0 - current iteration (0-indexed) #}
{# loop.first - True if first iteration #}
{# loop.last - True if last iteration #}
{# loop.length - total number of items #}
{# loop.revindex - iterations until the end (1-indexed) #}
Filters transform variable output. They are applied with the pipe (|) operator.
<!-- String filters -->
{{ name | capitalize }} <!-- "folau" -> "Folau" -->
{{ name | upper }} <!-- "folau" -> "FOLAU" -->
{{ name | lower }} <!-- "FOLAU" -> "folau" -->
{{ name | title }} <!-- "hello world" -> "Hello World" -->
{{ text | truncate(100) }} <!-- Truncates to 100 chars with "..." -->
{{ text | striptags }} <!-- Removes HTML tags -->
{{ text | wordcount }} <!-- Counts words -->
<!-- HTML escaping -->
{{ user_input | e }} <!-- Escapes HTML (default behavior) -->
{{ trusted_html | safe }} <!-- Marks content as safe, no escaping -->
<!-- List/Collection filters -->
{{ users | length }} <!-- Number of items -->
{{ numbers | sum }} <!-- Sum of numeric list -->
{{ names | join(', ') }} <!-- Joins list items -->
{{ items | sort }} <!-- Sorts items -->
{{ users | sort(attribute='name') }} <!-- Sort by attribute -->
{{ items | first }} <!-- First item -->
{{ items | last }} <!-- Last item -->
{{ items | unique }} <!-- Unique items -->
{{ items | reverse }} <!-- Reversed list -->
<!-- Number filters -->
{{ price | round(2) }} <!-- Rounds to 2 decimal places -->
{{ large_num | filesizeformat }} <!-- 13312 -> "13.0 kB" -->
<!-- Default values -->
{{ bio | default('No bio provided') }}
<!-- Chaining filters -->
{{ description | striptags | truncate(200) | capitalize }}
# Register custom filters in your Flask app
from datetime import datetime
@app.template_filter('dateformat')
def dateformat_filter(value, format='%B %d, %Y'):
if isinstance(value, str):
value = datetime.fromisoformat(value)
return value.strftime(format)
@app.template_filter('currency')
def currency_filter(value):
return f'${value:,.2f}'
<!-- Using custom filters -->
<p>Created: {{ task.created_at | dateformat }}</p>
<p>Created: {{ task.created_at | dateformat('%m/%d/%Y') }}</p>
<p>Price: {{ product.price | currency }}</p>
Macros are like functions for templates. They let you create reusable HTML components.
<!-- templates/macros/forms.html -->
{% macro input(name, label, type='text', value='', required=false, placeholder='') %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}"
id="{{ name }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
class="form-control"
{{ 'required' if required }}>
</div>
{% endmacro %}
{% macro textarea(name, label, value='', rows=4, required=false) %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<textarea id="{{ name }}"
name="{{ name }}"
rows="{{ rows }}"
class="form-control"
{{ 'required' if required }}>{{ value }}</textarea>
</div>
{% endmacro %}
{% macro select(name, label, options, selected='') %}
<div class="form-group">
<label for="{{ name }}">{{ label }}</label>
<select id="{{ name }}" name="{{ name }}" class="form-control">
{% for value, text in options %}
<option value="{{ value }}" {{ 'selected' if value == selected }}>
{{ text }}
</option>
{% endfor %}
</select>
</div>
{% endmacro %}
<!-- Using macros in another template -->
{% from "macros/forms.html" import input, textarea, select %}
<form method="POST">
{{ input('title', 'Task Title', required=true, placeholder='Enter task title') }}
{{ textarea('description', 'Description', rows=6) }}
{{ select('priority', 'Priority', [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]) }}
<button type="submit" class="btn btn-primary">Create Task</button>
</form>
<!-- templates/partials/task_card.html -->
<div class="card">
<div class="card-body">
<h5>{{ task.title }}</h5>
<p>{{ task.description | truncate(100) }}</p>
<span class="badge badge-{{ 'success' if task.done else 'warning' }}">
{{ 'Done' if task.done else 'Pending' }}
</span>
</div>
</div>
<!-- Including the partial -->
{% for task in tasks %}
{% include "partials/task_card.html" %}
{% endfor %}
<!-- Include with ignore missing (won't error if file is absent) -->
{% include "partials/sidebar.html" ignore missing %}
HTML forms are the standard way to collect user input on the web. Flask gives you direct access to submitted form data through the request object.
from flask import Flask, render_template, request, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = 'your-secret-key-here' # required for flash messages
tasks = []
@app.route('/tasks/create', methods=['GET', 'POST'])
def create_task():
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
# Validation
errors = []
if not title:
errors.append('Title is required.')
if len(title) > 200:
errors.append('Title must be under 200 characters.')
if errors:
for error in errors:
flash(error, 'danger')
return render_template('tasks/create.html',
title=title,
description=description,
priority=priority)
task = {
'id': len(tasks) + 1,
'title': title,
'description': description,
'priority': priority,
'done': False
}
tasks.append(task)
flash('Task created successfully!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/create.html')
<!-- templates/tasks/create.html -->
{% extends "base.html" %}
{% block content %}
<h1>Create New Task</h1>
<form method="POST" action="{{ url_for('create_task') }}">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title"
value="{{ title | default('') }}"
class="form-control" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"
class="form-control" rows="4">{{ description | default('') }}</textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-control">
<option value="low" {{ 'selected' if priority == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if priority == 'medium' else 'selected' if not priority }}>Medium</option>
<option value="high" {{ 'selected' if priority == 'high' }}>High</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create Task</button>
<a href="{{ url_for('task_list') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}
# In your view function
flash('Operation successful!', 'success') # green
flash('Please check your input.', 'warning') # yellow
flash('Something went wrong.', 'danger') # red
flash('FYI: system update tonight.', 'info') # blue
<!-- In your base template -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
Flask does not include CSRF protection by default. The Flask-WTF extension is the standard way to add it:
pip install flask-wtf
from flask_wtf import FlaskForm, CSRFProtect
from wtforms import StringField, TextAreaField, SelectField
from wtforms.validators import DataRequired, Length
app = Flask(__name__)
app.secret_key = 'your-secret-key'
csrf = CSRFProtect(app)
class TaskForm(FlaskForm):
title = StringField('Title', validators=[
DataRequired(message='Title is required'),
Length(max=200, message='Title must be under 200 characters')
])
description = TextAreaField('Description')
priority = SelectField('Priority',
choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')])
@app.route('/tasks/create', methods=['GET', 'POST'])
def create_task():
form = TaskForm()
if form.validate_on_submit():
task = {
'title': form.title.data,
'description': form.description.data,
'priority': form.priority.data
}
tasks.append(task)
flash('Task created!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/create_wtf.html', form=form)
<!-- templates/tasks/create_wtf.html -->
{% extends "base.html" %}
{% block content %}
<form method="POST">
{{ form.hidden_tag() }} <!-- Includes CSRF token -->
<div class="form-group">
{{ form.title.label }}
{{ form.title(class="form-control") }}
{% for error in form.title.errors %}
<small class="text-danger">{{ error }}</small>
{% endfor %}
</div>
<div class="form-group">
{{ form.description.label }}
{{ form.description(class="form-control", rows=4) }}
</div>
<div class="form-group">
{{ form.priority.label }}
{{ form.priority(class="form-control") }}
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
{% endblock %}
Flask serves static files from the static/ folder by default. Use url_for('static', filename=...) to reference them.
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- JavaScript -->
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<!-- Images -->
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Logo">
<!-- Favicon -->
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
static/ ├── css/ │ ├── style.css │ └── components/ │ └── navbar.css ├── js/ │ ├── app.js │ └── utils.js ├── img/ │ ├── logo.png │ └── icons/ └── favicon.ico
Browsers aggressively cache static files. When you update a CSS or JS file, users might still see the old version. A cache-busting strategy appends a version or hash to the file URL.
import hashlib
import os
@app.template_filter('cache_bust')
def cache_bust_filter(filename):
filepath = os.path.join(app.static_folder, filename)
if os.path.isfile(filepath):
with open(filepath, 'rb') as f:
file_hash = hashlib.md5(f.read()).hexdigest()[:8]
return url_for('static', filename=filename) + '?v=' + file_hash
return url_for('static', filename=filename)
<!-- Usage in templates -->
<link rel="stylesheet" href="{{ 'css/style.css' | cache_bust }}">
<!-- Output: /static/css/style.css?v=a1b2c3d4 -->
Let us build a complete task manager application that demonstrates routes, templates, forms, and flash messages working together. This is the kind of application structure you would use in a real project.
# app.py
from flask import Flask, render_template, request, redirect, url_for, flash
from datetime import datetime
app = Flask(__name__)
app.secret_key = 'dev-secret-key-change-in-production'
# In-memory storage (use a database in production)
tasks = [
{
'id': 1,
'title': 'Learn Flask routing',
'description': 'Study URL rules, methods, and variables',
'priority': 'high',
'done': False,
'created_at': datetime(2026, 2, 1)
},
{
'id': 2,
'title': 'Master Jinja2 templates',
'description': 'Learn inheritance, macros, and filters',
'priority': 'high',
'done': False,
'created_at': datetime(2026, 2, 5)
},
{
'id': 3,
'title': 'Build a CRUD app',
'description': 'Apply routing and templates in a real project',
'priority': 'medium',
'done': False,
'created_at': datetime(2026, 2, 10)
}
]
next_id = 4
def find_task(task_id):
return next((t for t in tasks if t['id'] == task_id), None)
@app.template_filter('dateformat')
def dateformat_filter(value, fmt='%B %d, %Y'):
return value.strftime(fmt)
# --- Routes ---
@app.route('/')
def index():
return redirect(url_for('task_list'))
@app.route('/tasks/')
def task_list():
filter_status = request.args.get('status', 'all')
sort_by = request.args.get('sort', 'created_at')
filtered = tasks[:]
if filter_status == 'done':
filtered = [t for t in filtered if t['done']]
elif filter_status == 'pending':
filtered = [t for t in filtered if not t['done']]
if sort_by == 'priority':
priority_order = {'high': 0, 'medium': 1, 'low': 2}
filtered.sort(key=lambda t: priority_order.get(t['priority'], 1))
elif sort_by == 'title':
filtered.sort(key=lambda t: t['title'].lower())
else:
filtered.sort(key=lambda t: t['created_at'], reverse=True)
return render_template('tasks/list.html',
tasks=filtered,
current_status=filter_status,
current_sort=sort_by)
@app.route('/tasks/create', methods=['GET', 'POST'])
def task_create():
global next_id
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
if not title:
flash('Title is required.', 'danger')
return render_template('tasks/form.html',
action='Create',
title=title,
description=description,
priority=priority)
task = {
'id': next_id,
'title': title,
'description': description,
'priority': priority,
'done': False,
'created_at': datetime.now()
}
tasks.append(task)
next_id += 1
flash(f'Task "{title}" created successfully!', 'success')
return redirect(url_for('task_list'))
return render_template('tasks/form.html', action='Create')
@app.route('/tasks/<int:task_id>')
def task_detail(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
return render_template('tasks/detail.html', task=task)
@app.route('/tasks/<int:task_id>/edit', methods=['GET', 'POST'])
def task_edit(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
if request.method == 'POST':
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
priority = request.form.get('priority', 'medium')
if not title:
flash('Title is required.', 'danger')
return render_template('tasks/form.html',
action='Edit',
task=task,
title=title,
description=description,
priority=priority)
task['title'] = title
task['description'] = description
task['priority'] = priority
flash(f'Task "{title}" updated successfully!', 'success')
return redirect(url_for('task_detail', task_id=task_id))
return render_template('tasks/form.html',
action='Edit',
task=task,
title=task['title'],
description=task['description'],
priority=task['priority'])
@app.route('/tasks/<int:task_id>/toggle', methods=['POST'])
def task_toggle(task_id):
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
task['done'] = not task['done']
status = 'completed' if task['done'] else 'reopened'
flash(f'Task "{task["title"]}" {status}.', 'info')
return redirect(url_for('task_list'))
@app.route('/tasks/<int:task_id>/delete', methods=['POST'])
def task_delete(task_id):
global tasks
task = find_task(task_id)
if task is None:
flash('Task not found.', 'danger')
return redirect(url_for('task_list'))
tasks = [t for t in tasks if t['id'] != task_id]
flash(f'Task "{task["title"]}" deleted.', 'warning')
return redirect(url_for('task_list'))
# Custom error handlers
@app.errorhandler(404)
def not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def server_error(e):
return render_template('errors/500.html'), 500
if __name__ == '__main__':
app.run(debug=True)
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Task Manager{% endblock %}</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="{{ url_for('task_list') }}">Task Manager</a>
<div class="navbar-nav">
<a class="nav-link" href="{{ url_for('task_list') }}">All Tasks</a>
<a class="nav-link" href="{{ url_for('task_create') }}">New Task</a>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
<!-- templates/tasks/list.html -->
{% extends "base.html" %}
{% block title %}All Tasks - Task Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Tasks</h1>
<a href="{{ url_for('task_create') }}" class="btn btn-primary">+ New Task</a>
</div>
<!-- Filters -->
<div class="btn-group mb-3">
<a href="{{ url_for('task_list', status='all', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'all' else 'btn-outline-primary' }}">
All
</a>
<a href="{{ url_for('task_list', status='pending', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'pending' else 'btn-outline-primary' }}">
Pending
</a>
<a href="{{ url_for('task_list', status='done', sort=current_sort) }}"
class="btn btn-sm {{ 'btn-primary' if current_status == 'done' else 'btn-outline-primary' }}">
Done
</a>
</div>
<!-- Sort -->
<div class="btn-group mb-3 ml-2">
<a href="{{ url_for('task_list', status=current_status, sort='created_at') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'created_at' else 'btn-outline-secondary' }}">
Newest
</a>
<a href="{{ url_for('task_list', status=current_status, sort='priority') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'priority' else 'btn-outline-secondary' }}">
Priority
</a>
<a href="{{ url_for('task_list', status=current_status, sort='title') }}"
class="btn btn-sm {{ 'btn-secondary' if current_sort == 'title' else 'btn-outline-secondary' }}">
A-Z
</a>
</div>
{% if tasks %}
<div class="list-group">
{% for task in tasks %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div>
<h5 class="{{ 'text-muted' if task.done }}">
{% if task.done %}<s>{{ task.title }}</s>{% else %}{{ task.title }}{% endif %}
<span class="badge badge-{{ 'danger' if task.priority == 'high' else 'warning' if task.priority == 'medium' else 'info' }}">
{{ task.priority | capitalize }}
</span>
</h5>
<small class="text-muted">Created {{ task.created_at | dateformat }}</small>
</div>
<div class="btn-group">
<a href="{{ url_for('task_detail', task_id=task.id) }}"
class="btn btn-sm btn-outline-info">View</a>
<a href="{{ url_for('task_edit', task_id=task.id) }}"
class="btn btn-sm btn-outline-secondary">Edit</a>
<form method="POST" action="{{ url_for('task_toggle', task_id=task.id) }}"
style="display:inline">
<button class="btn btn-sm btn-outline-{{ 'success' if not task.done else 'warning' }}">
{{ 'Complete' if not task.done else 'Reopen' }}
</button>
</form>
<form method="POST" action="{{ url_for('task_delete', task_id=task.id) }}"
style="display:inline"
onsubmit="return confirm('Delete this task?')">
<button class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
<p class="mt-3 text-muted">Showing {{ tasks | length }} task{{ 's' if tasks | length != 1 }}</p>
{% else %}
<div class="text-center py-5">
<h3 class="text-muted">No tasks found</h3>
<p><a href="{{ url_for('task_create') }}">Create your first task</a></p>
</div>
{% endif %}
{% endblock %}
<!-- templates/tasks/form.html -->
{% extends "base.html" %}
{% block title %}{{ action }} Task - Task Manager{% endblock %}
{% block content %}
<h1>{{ action }} Task</h1>
<form method="POST">
<div class="form-group">
<label for="title">Title <span class="text-danger">*</span></label>
<input type="text" id="title" name="title"
value="{{ title | default('') }}"
class="form-control" required maxlength="200"
placeholder="What needs to be done?">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"
class="form-control" rows="4"
placeholder="Add details (optional)">{{ description | default('') }}</textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="form-control">
<option value="low" {{ 'selected' if priority == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if priority == 'medium' or not priority }}>Medium</option>
<option value="high" {{ 'selected' if priority == 'high' }}>High</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ action }} Task</button>
<a href="{{ url_for('task_list') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}
<!-- templates/tasks/detail.html -->
{% extends "base.html" %}
{% block title %}{{ task.title }} - Task Manager{% endblock %}
{% block content %}
<div class="card">
<div class="card-body">
<h2>
{{ task.title }}
<span class="badge badge-{{ 'success' if task.done else 'warning' }}">
{{ 'Done' if task.done else 'Pending' }}
</span>
<span class="badge badge-{{ 'danger' if task.priority == 'high' else 'warning' if task.priority == 'medium' else 'info' }}">
{{ task.priority | capitalize }} Priority
</span>
</h2>
{% if task.description %}
<p class="card-text mt-3">{{ task.description }}</p>
{% endif %}
<p class="text-muted">Created: {{ task.created_at | dateformat }}</p>
<div class="mt-3">
<a href="{{ url_for('task_edit', task_id=task.id) }}"
class="btn btn-secondary">Edit</a>
<form method="POST" action="{{ url_for('task_toggle', task_id=task.id) }}"
style="display:inline">
<button class="btn btn-{{ 'success' if not task.done else 'warning' }}">
Mark {{ 'Complete' if not task.done else 'Incomplete' }}
</button>
</form>
<form method="POST" action="{{ url_for('task_delete', task_id=task.id) }}"
style="display:inline"
onsubmit="return confirm('Are you sure you want to delete this task?')">
<button class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
<a href="{{ url_for('task_list') }}" class="btn btn-link mt-3">← Back to list</a>
{% endblock %}
# Install Flask pip install flask # Run the application python app.py # Or use the flask command export FLASK_APP=app.py export FLASK_ENV=development flask run # Open http://localhost:5000 in your browser
Here are the mistakes I see developers make most often when working with Flask routes and templates.
# WRONG - This only handles GET, POST will return 405 Method Not Allowed
@app.route('/submit')
def submit():
data = request.form['name'] # never reached on POST
return 'OK'
# CORRECT
@app.route('/submit', methods=['GET', 'POST'])
def submit():
if request.method == 'POST':
data = request.form['name']
return 'OK'
return render_template('form.html')
# Flask looks for templates in the 'templates/' folder by default.
# Make sure your directory structure is correct:
#
# myapp/
# ├── app.py
# └── templates/ <-- must be named "templates"
# └── index.html
# WRONG - file at myapp/index.html (not in templates folder)
# CORRECT - file at myapp/templates/index.html
# If using blueprints with custom template folders, check template_folder parameter:
bp = Blueprint('auth', __name__, template_folder='templates')
# Template paths are relative: render_template('auth/login.html')
# WRONG - using the URL path instead of the function name
url_for('/user/profile')
# CORRECT - use the endpoint (function name)
url_for('user_profile')
# WRONG - missing required URL variables
url_for('show_post') # BuildError: missing 'post_id'
# CORRECT
url_for('show_post', post_id=42)
# WRONG - blueprint endpoint without blueprint prefix
url_for('login') # BuildError if login is in auth blueprint
# CORRECT
url_for('auth.login')
# This is a common problem with Flask blueprints.
# WRONG:
# app.py imports blueprints/auth.py
# blueprints/auth.py imports app from app.py -> circular import
# CORRECT - use Flask application factory pattern:
# app/__init__.py
def create_app():
app = Flask(__name__)
from .blueprints.auth import auth_bp
app.register_blueprint(auth_bp)
return app
# WRONG - code after return never executes
@app.route('/example')
def example():
return 'Hello'
response.headers['X-Custom'] = 'value' # unreachable
# CORRECT - use make_response before returning
@app.route('/example')
def example():
resp = make_response('Hello')
resp.headers['X-Custom'] = 'value'
return resp
# Use nouns, not verbs. Let HTTP methods express the action.
# WRONG
@app.route('/get-users')
@app.route('/create-user')
@app.route('/delete-user/1')
# CORRECT
@app.route('/users/', methods=['GET']) # list users
@app.route('/users/', methods=['POST']) # create user
@app.route('/users/<int:id>', methods=['GET']) # get user
@app.route('/users/<int:id>', methods=['PUT']) # update user
@app.route('/users/<int:id>', methods=['DELETE']) # delete user
# Use plural nouns for collections
# WRONG: /user/1
# CORRECT: /users/1
# Nest resources logically
# /users/1/posts - posts by user 1
# /users/1/posts/5 - post 5 by user 1
# WRONG - business logic crammed into the view
@app.route('/checkout', methods=['POST'])
def checkout():
cart = session.get('cart', [])
total = sum(item['price'] * item['qty'] for item in cart)
tax = total * 0.08
shipping = 5.99 if total < 50 else 0
final_total = total + tax + shipping
# ... 50 more lines of order processing
return render_template('receipt.html', total=final_total)
# CORRECT - delegate to service functions
@app.route('/checkout', methods=['POST'])
def checkout():
cart = get_cart_from_session()
order = create_order(cart)
return render_template('receipt.html', order=order)
Do not wait until your app.py is 2000 lines long. Start with blueprints as soon as you have more than one logical section (auth, API, admin, public pages). The refactoring cost grows with time.
# Organize templates to mirror your application structure
templates/
├── base.html # Master layout
├── macros/
│ ├── forms.html # Reusable form components
│ └── pagination.html # Pagination macro
├── partials/
│ ├── navbar.html # Navigation
│ ├── footer.html # Footer
│ └── flash_messages.html
├── auth/
│ ├── login.html
│ └── register.html
├── tasks/
│ ├── list.html
│ ├── detail.html
│ └── form.html
└── errors/
├── 404.html
└── 500.html
app.secret_key to a long random value in production.secure_filename() from Werkzeug for file uploads.MAX_CONTENT_LENGTH to prevent large file upload attacks.| safe on content you trust.@app.route() with explicit methods parameters. Understand trailing slash behavior.int, float, path, uuid) to validate input at the routing layer.request.args), form data (request.form), JSON (request.get_json()), and files (request.files).make_response() objects, jsonify() for APIs, or redirect() for navigation.extends, block) eliminates HTML duplication. Use macros for reusable components and includes for partials.With routes and templates mastered, you have the foundation for building any Flask web application. The next steps are adding a database with Flask-SQLAlchemy, user authentication with Flask-Login, and deploying to production. Each of these builds directly on the routing and templating patterns covered here.