
Building a Web App with Flask: Routes, Templates, and Forms
Flask is the framework people reach for when they want to see every moving part. A complete app can fit in one file, there's no project generator, and nothing happens that you didn't ask for. That minimalism is also why Flask is a good way to learn how server-rendered web apps work in general: routing, templates, and form handling are all right there in plain Python.
This guide builds a small bookmarks app with three core pieces: routes that map URLs to Python functions, templates that turn data into HTML, and forms that accept and validate user input. You'll write form handling by hand first so you can see what's going on, then switch to Flask-WTF for CSRF protection and declarative validation.
The code was tested with Flask 3.1 on Python 3.13.
Setup
Create a virtual environment and install Flask:
mkdir bookmarks && cd bookmarks
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install flask
The final project looks like this:
bookmarks/
├── app.py
├── forms.py
├── static/
│ └── style.css
└── templates/
├── _macros.html
├── 404.html
├── base.html
├── detail.html
├── form.html
└── index.html
Flask finds templates/ and static/ next to the module that creates the app, so the folder names matter.
Routes
The smallest useful Flask app:
# hello.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello, Flask!"
@app.route("/users/<username>")
def profile(username: str):
return f"Profile page for {username}"
@app.route("/posts/<int:post_id>")
def show_post(post_id: int):
return {"id": post_id, "type": type(post_id).__name__}
Run it with the flask command. --debug turns on the reloader and the interactive debugger:
flask --app hello run --debug
The @app.route decorator registers a function as the view for a URL rule. Whatever the view returns becomes the response: a string becomes an HTML body, a dict or list becomes JSON, and a tuple like (body, 404) sets the status code too.
Variable Rules and Converters
Angle brackets capture part of the URL and pass it to the view as a keyword argument. A converter in front controls what matches and what type you get:
| Rule | Matches | View receives |
|---|---|---|
<username> or <string:username> | Any text without a slash | str |
<int:post_id> | Positive integers | int |
<float:price> | Positive floats | float |
<path:subpath> | Text including slashes | str |
<uuid:token> | UUID strings | uuid.UUID |
With the int converter, /posts/42 returns {"id": 42, "type": "int"}, and /posts/abc doesn't match at all, so Flask returns a 404 without calling your function. That's validation for free.
One caution about the profile view above: returning an f-string puts user input straight into HTML. Visiting /users/<script>... would inject markup. Templates escape values automatically, which is one of several reasons to use them for HTML. If you must build HTML in Python, wrap values with markupsafe.escape().
HTTP Methods
Routes accept GET only by default. Pass methods to allow others, or use the shortcut decorators:
@app.route("/bookmarks/new", methods=["GET", "POST"])
def create(): ...
@app.post("/bookmarks/<int:bookmark_id>/delete")
def delete(bookmark_id: int): ...
@app.get, @app.post, @app.put, @app.patch, and @app.delete each register a single method. Anything that changes data should use POST (or another non-GET method), because browsers, crawlers and prefetchers freely follow GET links.
Building URLs with url_for
Don't hardcode paths. url_for() builds a URL from the view function's name (its endpoint) and arguments:
from flask import url_for
url_for("profile", username="grace") # '/users/grace'
url_for("show_post", post_id=7, ref="home") # '/posts/7?ref=home'
Arguments that match a variable in the rule fill it in; extra ones become query-string parameters. If you later change /users/<username> to /u/<username>, every link built with url_for updates automatically.
The Bookmarks App
Now the real app. To keep the focus on Flask, bookmarks live in an in-memory dictionary: they vanish when the server restarts, and they aren't shared across multiple worker processes. In a real app you'd swap this for a database (see using Python with databases or SQLAlchemy 2.0).
# app.py
from dataclasses import dataclass, field
from datetime import datetime, timezone
from itertools import count
from urllib.parse import urlparse
from flask import Flask, abort, flash, redirect, render_template, request, url_for
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-only-change-me"
@dataclass
class Bookmark:
id: int
title: str
url: str
tags: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# In-memory storage keeps the example focused on Flask itself.
# Swap this for a real database in a real app.
BOOKMARKS: dict[int, Bookmark] = {}
_next_id = count(1)
def add_bookmark(title: str, url: str, tags: list[str]) -> Bookmark:
bookmark = Bookmark(id=next(_next_id), title=title, url=url, tags=tags)
BOOKMARKS[bookmark.id] = bookmark
return bookmark
add_bookmark("Flask docs", "https://flask.palletsprojects.com/", ["python", "docs"])
add_bookmark("Jinja docs", "https://jinja.palletsprojects.com/", ["templates"])
@app.route("/")
def index():
tag = request.args.get("tag")
bookmarks = list(BOOKMARKS.values())
if tag:
bookmarks = [b for b in bookmarks if tag in b.tags]
bookmarks.sort(key=lambda b: b.created_at, reverse=True)
return render_template("index.html", bookmarks=bookmarks, tag=tag)
@app.route("/bookmarks/<int:bookmark_id>")
def detail(bookmark_id: int):
bookmark = BOOKMARKS.get(bookmark_id)
if bookmark is None:
abort(404)
return render_template("detail.html", bookmark=bookmark)
A few Flask features show up already:
SECRET_KEYsigns the session cookie, which flash messages and CSRF tokens depend on. In production, load it from an environment variable and make it long and random.request.argsholds query-string parameters.request.args.get("tag")returnsNonewhen the parameter is missing, so/shows everything and/?tag=docsfilters.render_template()renders a file fromtemplates/with the keyword arguments as template variables.abort(404)raises an HTTP exception that stops the view immediately.
The request object looks like a global, but Flask makes it refer to the current request in whichever thread or task is handling it, so you can import it at the top of the module and use it in any view.
Templates with Jinja
Flask uses Jinja for templates. The syntax has three parts: {{ expression }} prints a value, {% statement %} runs control flow, and {# comment #} is ignored.
Template Inheritance
Put the shared page structure in a base template and define blocks that child templates fill in:
{# templates/base.html #}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Bookmarks{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('index') }}">All bookmarks</a>
<a href="{{ url_for('create') }}">Add bookmark</a>
</nav>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<p class="flash flash-{{ category }}">{{ message }}</p>
{% endfor %}
{% endwith %}
<main>{% block content %}{% endblock %}</main>
</body>
</html>
url_for works inside templates too, and url_for('static', filename='style.css') points at files in the static/ folder. The flash-message loop displays one-time messages set by views, covered below.
A child template extends the base and overrides only the blocks it needs:
{# templates/index.html #}
{% extends "base.html" %}
{% block content %}
<h1>{% if tag %}Tagged “{{ tag }}”{% else %}All bookmarks{% endif %}</h1>
{% for bookmark in bookmarks %}
<article>
<h2><a href="{{ url_for('detail', bookmark_id=bookmark.id) }}">{{ bookmark.title }}</a></h2>
<p>{{ bookmark.url | domain }}</p>
{% for t in bookmark.tags %}
<a href="{{ url_for('index', tag=t) }}">#{{ t }}</a>
{% endfor %}
</article>
{% else %}
<p>No bookmarks yet. <a href="{{ url_for('create') }}">Add one</a>.</p>
{% endfor %}
{% endblock %}
Jinja's for loop takes an else branch that runs when the sequence is empty, which saves a separate if. Attribute access (bookmark.title) works for objects and dictionaries alike.
The detail page uses the object's own methods, which Jinja lets you call with arguments:
{# templates/detail.html #}
{% extends "base.html" %}
{% block title %}{{ bookmark.title }} · Bookmarks{% endblock %}
{% block content %}
<h1>{{ bookmark.title }}</h1>
<p><a href="{{ bookmark.url }}" rel="noopener">{{ bookmark.url }}</a></p>
<p>Saved {{ bookmark.created_at.strftime("%b %d, %Y") }}</p>
<form method="post" action="{{ url_for('delete', bookmark_id=bookmark.id) }}">
<button type="submit">Delete</button>
</form>
{% endblock %}
Auto-Escaping
Flask turns on auto-escaping for .html templates. If someone saves a bookmark titled <script>alert(1)</script>, the page shows it as text, because {{ bookmark.title }} outputs <script>.... Only mark content safe (with the |safe filter or Markup) when you generated the HTML yourself and know it's clean.
Escaping protects HTML contexts. It doesn't make every attribute safe: a user-supplied javascript: URL in an href is still dangerous, which is one reason the form below only accepts http and https URLs.
Custom Filters
Filters transform a value with the pipe syntax. Jinja ships many (upper, length, default, join, truncate), and you can register your own:
# app.py
@app.template_filter("domain")
def domain_filter(url: str) -> str:
return urlparse(url).netloc.removeprefix("www.")
Now {{ bookmark.url | domain }} renders flask.palletsprojects.com. Filters keep formatting logic out of templates without bloating your views.
Custom Error Pages
abort(404) and unmatched URLs both produce Flask's plain default page. Register a handler to use your own template:
# app.py
@app.errorhandler(404)
def not_found(error):
return render_template("404.html"), 404
{# templates/404.html #}
{% extends "base.html" %}
{% block title %}Not found{% endblock %}
{% block content %}
<h1>Page not found</h1>
<p><a href="{{ url_for('index') }}">Back to your bookmarks</a></p>
{% endblock %}
Return the status code explicitly. A handler that returns only the template sends a 200, which confuses search engines and monitoring.
Handling Forms by Hand
Form handling in any server-rendered app follows the same loop:
- GET: show an empty form.
- POST: read the submitted values and validate them.
- Invalid: re-render the form with the user's input and error messages.
- Valid: save, set a message, and redirect.
Here's that loop with nothing but Flask:
# app.py
def validate(form) -> tuple[dict[str, str], dict[str, str]]:
data = {
"title": form.get("title", "").strip(),
"url": form.get("url", "").strip(),
"tags": form.get("tags", "").strip(),
}
errors: dict[str, str] = {}
if not data["title"]:
errors["title"] = "Title is required."
elif len(data["title"]) > 100:
errors["title"] = "Keep the title under 100 characters."
parsed = urlparse(data["url"])
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
errors["url"] = "Enter a full URL starting with http:// or https://."
return data, errors
@app.route("/bookmarks/new", methods=["GET", "POST"])
def create():
data = {"title": "", "url": "", "tags": ""}
errors: dict[str, str] = {}
if request.method == "POST":
data, errors = validate(request.form)
if not errors:
tags = [t.strip().lower() for t in data["tags"].split(",") if t.strip()]
bookmark = add_bookmark(data["title"], data["url"], tags)
flash(f"Saved “{bookmark.title}”.", "success")
return redirect(url_for("detail", bookmark_id=bookmark.id))
status = 422 if errors else 200
return render_template("form.html", data=data, errors=errors), status
@app.post("/bookmarks/<int:bookmark_id>/delete")
def delete(bookmark_id: int):
bookmark = BOOKMARKS.pop(bookmark_id, None)
if bookmark is None:
abort(404)
flash(f"Deleted “{bookmark.title}”.", "info")
return redirect(url_for("index"))
What's going on:
request.formholds POSTed form fields. Use.get()with a default; indexing withrequest.form["title"]raises aKeyErrorthat Flask turns into a 400 Bad Request if the field is missing.- Validation returns the cleaned values and a dictionary of errors keyed by field name. When errors exist, the form re-renders with what the user typed, so they don't lose their work.
- Returning status 422 on validation errors is a small courtesy for tests and tools; browsers display the page either way.
- After a successful save, the view redirects instead of rendering. This is the Post/Redirect/Get pattern: if the user refreshes the next page, the browser repeats a harmless GET rather than resubmitting the form.
flash()stores a message in the session for exactly one subsequent request. The loop inbase.htmldisplays and clears it. The second argument is a category you can use for styling.
The template shows the values and errors:
{# templates/form.html #}
{% extends "base.html" %}
{% block title %}Add bookmark{% endblock %}
{% block content %}
<h1>Add a bookmark</h1>
<form method="post" novalidate>
<label for="title">Title</label>
<input id="title" name="title" value="{{ data.title }}">
{% if errors.title %}<p class="error">{{ errors.title }}</p>{% endif %}
<label for="url">URL</label>
<input id="url" name="url" type="url" value="{{ data.url }}">
{% if errors.url %}<p class="error">{{ errors.url }}</p>{% endif %}
<label for="tags">Tags (comma separated)</label>
<input id="tags" name="tags" value="{{ data.tags }}">
<button type="submit">Save</button>
</form>
{% endblock %}
The name attributes are the keys in request.form. novalidate disables the browser's own validation during development so you can see your server-side errors; you can remove it later, but never rely on browser validation alone, because anyone can send a POST request without your form.
This works, but it's missing something important: CSRF protection. Any other website could include a hidden form that posts to /bookmarks/<id>/delete and trick a logged-in user's browser into submitting it. Flask doesn't add CSRF tokens on its own. That's the main reason to bring in Flask-WTF.
Forms with Flask-WTF
Flask-WTF integrates the WTForms library with Flask. You declare fields and validators as a class, and it adds a signed CSRF token to every form automatically.
python -m pip install flask-wtf
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, URLField
from wtforms.validators import URL, DataRequired, Length, ValidationError
class BookmarkForm(FlaskForm):
title = StringField("Title", validators=[DataRequired(), Length(max=100)])
url = URLField("URL", validators=[DataRequired(), URL(require_tld=True)])
tags = StringField("Tags (comma separated)")
def validate_tags(self, field: StringField) -> None:
tags = [t.strip() for t in (field.data or "").split(",") if t.strip()]
if len(tags) > 5:
raise ValidationError("Use at most five tags.")
Each field takes a label and a list of validators. A method named validate_<fieldname> adds custom validation for that field, and raising ValidationError attaches the message to it. Note the field.data or "": when a field is missing from the submission entirely, its data is None, and calling .split() on that would crash the request.
The view gets shorter, because validate_on_submit() checks that the request is a POST, verifies the CSRF token, and runs every validator in one call:
# app.py (replaces the hand-written create view)
from forms import BookmarkForm
@app.route("/bookmarks/new", methods=["GET", "POST"])
def create():
form = BookmarkForm()
if form.validate_on_submit():
tags = [t.strip().lower() for t in (form.tags.data or "").split(",") if t.strip()]
bookmark = add_bookmark(form.title.data, form.url.data, tags)
flash(f"Saved “{bookmark.title}”.", "success")
return redirect(url_for("detail", bookmark_id=bookmark.id))
return render_template("form.html", form=form)
FlaskForm() reads request.form automatically, so on a failed POST the form object already holds the user's input and the error messages.
Rendering Fields with a Macro
Rendering each field with its label and errors gets repetitive. A Jinja macro is a reusable template function:
{# templates/_macros.html #}
{% macro render_field(field) %}
<div class="field">
{{ field.label }}
{{ field(**kwargs) }}
{% for error in field.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endmacro %}
{# templates/form.html #}
{% extends "base.html" %}
{% from "_macros.html" import render_field %}
{% block title %}Add bookmark{% endblock %}
{% block content %}
<h1>Add a bookmark</h1>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.title, autofocus=true) }}
{{ render_field(form.url, placeholder="https://") }}
{{ render_field(form.tags) }}
<button type="submit">Save</button>
</form>
{% endblock %}
form.hidden_tag() outputs the hidden CSRF token input. Calling a field like field(**kwargs) renders its <input> with any extra attributes you pass, and WTForms adds some on its own; the title field renders as:
<input
autofocus
id="title"
maxlength="100"
name="title"
required
type="text"
value=""
/>
The maxlength and required attributes come from the Length and DataRequired validators.
One gotcha: if you forget {{ form.hidden_tag() }}, validate_on_submit() returns False on every submission, and because the macro only shows errors for visible fields, the form just silently refuses to save. Checking form.errors in a debugger reveals {'csrf_token': ['The CSRF token is missing.']}.
For forms that aren't built with WTForms, such as the delete button on the detail page, enable CSRFProtect(app) from flask_wtf.csrf and add <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> to them. That protects every POST view in the app.
Testing the App
Flask's test client sends requests to your app without starting a server. With pytest, a fixture sets it up once:
# test_app.py
import pytest
from app import BOOKMARKS, app
@pytest.fixture
def client():
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
with app.test_client() as client:
yield client
def test_index_lists_bookmarks(client):
response = client.get("/")
assert response.status_code == 200
assert b"Flask docs" in response.data
def test_filter_by_tag(client):
response = client.get("/?tag=templates")
assert b"Jinja docs" in response.data
assert b"Flask docs" not in response.data
def test_create_redirects_and_flashes(client):
response = client.post(
"/bookmarks/new",
data={"title": "PEP 8", "url": "https://peps.python.org/pep-0008/", "tags": "Python, Style"},
follow_redirects=True,
)
assert response.status_code == 200
assert "Saved “PEP 8”.".encode() in response.data
assert any(b.tags == ["python", "style"] for b in BOOKMARKS.values())
def test_missing_bookmark_is_404(client):
response = client.get("/bookmarks/999")
assert response.status_code == 404
assert b"Page not found" in response.data
WTF_CSRF_ENABLED = False lets tests post forms without fetching a token first. follow_redirects=True follows the redirect so you can assert on the final page, flash message included.
Growing Beyond One File
A single app.py is fine for small projects. As an app grows, two patterns keep it organized:
- Application factory. Instead of a module-level
app, write acreate_app()function that builds and configures the app. That makes it easy to create differently configured instances for tests, andflask --appfindscreate_appautomatically. - Blueprints. A
Blueprintgroups related routes, templates and static files (say,authandbookmarks) and is registered on the app withapp.register_blueprint(). Endpoints become namespaced, as inurl_for("bookmarks.detail", ...).
For production, run Flask behind a WSGI server such as Gunicorn rather than flask run. Deploying a Python web app with Docker covers that setup, and if you're weighing Flask against the alternatives, see Django vs Flask vs FastAPI.
Conclusion
Most of a Flask app comes down to three ideas. Routes map URLs (with typed converters) to view functions, and url_for builds links back to them. Jinja templates use inheritance, filters and macros to turn data into escaped HTML. Forms follow the GET, validate, re-render or redirect loop, with flash messages for feedback. Writing that loop by hand once shows you exactly what Flask-WTF automates, and Flask-WTF adds the CSRF protection you shouldn't ship without.


