Simple Blog with Flask
Abstract
Section titled “Abstract”A blog is the “to-do app” of web frameworks — small enough to finish, complete enough to teach everything. This Flask project does the full CRUD-lite loop: list posts, view a single post by ID, and submit new posts through an HTML form. You’ll learn routing, dynamic URLs, form handling with GET vs. POST, and Jinja templating. Then you’ll fix the elephant in the room — posts vanish on restart because they live in a list — by moving to a SQLite database, and round it out with edit, delete, and validation.
You will leave understanding:
- How Flask routes URLs to view functions, including dynamic
<int:post_id>. - The GET-to-show-form, POST-to-submit pattern and why you redirect after POST.
- How Jinja templates render data passed from views.
- Why in-memory storage fails and how a database fixes it.
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE.
- Flask:
pip install flask. - Basic HTML.
- Understanding of functions, lists, and dicts.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
flask-blogwith atemplates/subfolder. - Inside it, create
simple_blog_with_flask.py. - Install Flask:
pip install flask.
Templates
Section titled “Templates”Flask renders HTML from the templates/ folder:
<h1>Blog</h1>
<a href="{{ url_for('new_post') }}">New Post</a>
<ul>
{% for post in posts %}
<li><a href="{{ url_for('view_post', post_id=post.id) }}">{{ post.title }}</a></li>
{% endfor %}
</ul><h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<a href="{{ url_for('index') }}">Back</a><h1>New Post</h1>
<form method="POST">
<input name="title" placeholder="Title" required><br>
<textarea name="content" placeholder="Write here..." required></textarea><br>
<button type="submit">Publish</button>
</form>Write the code
Section titled “Write the code”simple_blog_with_flask.py
pch.viewSource"""
Simple Blog with Flask
A Python application that simulates a simple blog using Flask.
Features include:
- Displaying a list of blog posts.
- Adding new blog posts.
- Viewing individual blog posts.
"""
import os
import sys
from flask import Flask, render_template, request, redirect, url_for
app = Flask(__name__)
# Sample data for blog posts
blog_posts = [
{"id": 1, "title": "First Post", "content": "This is the content of the first post."},
{"id": 2, "title": "Second Post", "content": "This is the content of the second post."},
]
@app.route('/')
def index():
"""Display the list of blog posts."""
return render_template('index.html', posts=blog_posts)
@app.route('/post/<int:post_id>')
def view_post(post_id):
"""View an individual blog post."""
post = next((p for p in blog_posts if p['id'] == post_id), None)
if post:
return render_template('post.html', post=post)
return "Post not found", 404
@app.route('/new', methods=['GET', 'POST'])
def new_post():
"""Add a new blog post."""
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
new_id = max(p['id'] for p in blog_posts) + 1 if blog_posts else 1
blog_posts.append({"id": new_id, "title": title, "content": content})
return redirect(url_for('index'))
return render_template('new_post.html')
# Flask looks for templates on disk, so a self-contained single-file project
# has to put them there before the first request. Keeping them as strings
# means the project stays one file; writing them at startup means Jinja can
# actually find them.
TEMPLATES = {
"base.html": """<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{% block title %}Blog{% endblock %}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
article { border-bottom: 1px solid #ddd; padding: 1rem 0; }
a { color: #06c; }
</style></head>
<body>
<h1><a href="{{ url_for('index') }}">Simple Blog</a></h1>
{% block body %}{% endblock %}
</body></html>""",
"index.html": """{% extends "base.html" %}
{% block body %}
<p><a href="{{ url_for('new_post') }}">Write a new post</a></p>
{% for post in posts %}
<article>
<h2><a href="{{ url_for('view_post', post_id=post.id) }}">{{ post.title }}</a></h2>
<p>{{ post.content }}</p>
</article>
{% else %}
<p>No posts yet.</p>
{% endfor %}
{% endblock %}""",
"post.html": """{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block body %}
<article><h2>{{ post.title }}</h2><p>{{ post.content }}</p></article>
<p><a href="{{ url_for('index') }}">Back to all posts</a></p>
{% endblock %}""",
"new_post.html": """{% extends "base.html" %}
{% block title %}New post{% endblock %}
{% block body %}
<form method="post">
<p><input name="title" placeholder="Title" required></p>
<p><textarea name="content" rows="8" placeholder="Content" required></textarea></p>
<p><button type="submit">Publish</button></p>
</form>
{% endblock %}""",
}
def write_templates():
"""Put the templates where Jinja will look for them."""
os.makedirs("templates", exist_ok=True)
for name, body in TEMPLATES.items():
with open(os.path.join("templates", name), "w",
encoding="utf-8") as handle:
handle.write(body)
def smoke_test():
"""Exercise every GET route once, without starting a server.
`app.test_client()` dispatches a real request through the real application
object -- no socket, no port, no waiting. A web project that cannot be
driven this way cannot be tested either.
It returns the number of routes that did **not** answer 2xx, and the
caller turns that into an exit code. The earlier version printed the
status and exited 0 regardless, so this file reported success while every
content route returned 500.
"""
print("smoke test: dispatching one request per route\n")
broken = 0
with app.test_client() as client:
rules = sorted(app.url_map.iter_rules(), key=lambda rule: str(rule))
checked = 0
for rule in rules:
if "GET" not in rule.methods or rule.arguments:
continue
response = client.get(str(rule))
body = " ".join(response.get_data(as_text=True).split())[:60]
if response.status_code >= 400:
broken += 1
print(f" GET {str(rule):26} {response.status_code} {body}")
checked += 1
print(f"\n{checked} route(s) answered, {broken} failing. "
f"Pass --serve to start the real server instead.")
return broken
if __name__ == "__main__":
# Templates first: they are what the routes render, and rendering them
# before they exist is what made every content route 500.
write_templates()
# Serving is opt-in, because a run that never returns cannot be tested or
# captured. With no arguments the file answers every route once and exits.
if "--serve" in sys.argv:
app.run(debug=True)
else:
raise SystemExit(1 if smoke_test() else 0) Run it
Section titled “Run it”C:\Users\Your Name\flask-blog> python simple_blog_with_flask.py
# Open http://127.0.0.1:5000 — see posts, click one, or add a new one.What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.6 s and prints:
smoke test: dispatching one request per route
GET / 200 <!DOCTYPE html> <html lang="en"> <head><meta charset="utf-8"
GET /new 200 <!DOCTYPE html> <html lang="en"> <head><meta charset="utf-8"
2 route(s) answered, 0 failing. Pass --serve to start the real server instead.How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD
RUN(["python simple_blog_with_flask.py"])
index("index")
view_post("view_post")
new_post("new_post")
smoke_test("smoke_test")
RUN --> smoke_test
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Listing posts
Section titled “1. Listing posts”@app.route('/')
def index():
return render_template('index.html', posts=blog_posts)The home route passes the blog_posts list into index.html, where Jinja loops over it. Data flows from Python to HTML through render_template keyword arguments.
2. Viewing one post (dynamic URL)
Section titled “2. Viewing one post (dynamic URL)”@app.route('/post/<int:post_id>')
def view_post(post_id):
post = next((p for p in blog_posts if p['id'] == post_id), None)
if post:
return render_template('post.html', post=post)
return "Post not found", 404<int:post_id> captures the number from /post/2 and passes it in. The next(...) finds the matching post; if none, returning a string + 404 sends a proper Not Found status. Handling the missing case is what separates robust routes from fragile ones.
3. Adding a post (GET vs POST)
Section titled “3. Adding a post (GET vs POST)”@app.route('/new', methods=['GET', 'POST'])
def new_post():
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
new_id = max(p['id'] for p in blog_posts) + 1 if blog_posts else 1
blog_posts.append({"id": new_id, "title": title, "content": content})
return redirect(url_for('index'))
return render_template('new_post.html')One route, two behaviors: a GET shows the empty form, a POST processes the submission. After saving, it redirects to the home page — the Post/Redirect/Get pattern that prevents a refresh from re-submitting the post. The new_id line generates the next ID.
The Real Problem: Posts Don’t Survive Restart
Section titled “The Real Problem: Posts Don’t Survive Restart”blog_posts is a Python list in memory — restart the server and every post is gone. Move storage to SQLite:
import sqlite3
from flask import g
def get_db():
if "db" not in g:
g.db = sqlite3.connect("blog.db")
g.db.row_factory = sqlite3.Row # rows behave like dicts
return g.db
# one-time setup:
# CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT);
def all_posts():
return get_db().execute("SELECT * FROM posts").fetchall()
def add_post(title, content):
db = get_db()
db.execute("INSERT INTO posts (title, content) VALUES (?, ?)", (title, content))
db.commit()AUTOINCREMENT replaces the manual new_id, and ? placeholders keep you safe from SQL injection. Now posts persist.
Add Edit and Delete
Section titled “Add Edit and Delete”Complete the CRUD set:
@app.route('/edit/<int:post_id>', methods=['GET', 'POST'])
def edit_post(post_id):
if request.method == 'POST':
get_db().execute("UPDATE posts SET title=?, content=? WHERE id=?",
(request.form['title'], request.form['content'], post_id))
get_db().commit()
return redirect(url_for('view_post', post_id=post_id))
post = get_db().execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
return render_template('edit_post.html', post=post)
@app.route('/delete/<int:post_id>', methods=['POST'])
def delete_post(post_id):
get_db().execute("DELETE FROM posts WHERE id=?", (post_id,))
get_db().commit()
return redirect(url_for('index'))Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Posts disappear on restart | Stored in a Python list | Persist to SQLite |
TemplateNotFound | HTML not in templates/ | Use the templates/ folder |
400 Bad Request on submit | request.form['x'] missing key | Match form name= attributes; use .get() |
| Refresh re-posts the entry | No redirect after POST | Redirect (PRG pattern) |
Crash on /post/999 | Assumed the post exists | Handle None → return 404 |
| HTML shows as raw tags | Auto-escaping confusion | Trust Jinja’s escaping; don’t disable it |
Variations to Try
Section titled “Variations to Try”- SQLite persistence — the essential upgrade above.
- Edit & delete — full CRUD.
- Markdown posts — render content with a Markdown library.
- Comments — a second table linked to posts.
- Authentication — login so only you can post (see REST API with Authentication).
- Pagination —
LIMIT/OFFSETfor many posts. - Tags & search — categorize and filter posts.
- Base template — extract a shared layout with Jinja
{% extends %}.
Real-World Applications
Section titled “Real-World Applications”- Blogs & CMSs — WordPress and Ghost are this idea, scaled up.
- Documentation sites — content lists + detail pages.
- Internal wikis — team knowledge bases.
- Learning Flask — the canonical first real web app.
Educational Value
Section titled “Educational Value”- Routing — static and dynamic URLs.
- Forms — GET/POST,
request.form, and the PRG pattern. - Templating — passing data to Jinja, loops,
url_for. - Persistence — moving from memory to a real database.
Next Steps
Section titled “Next Steps”- Move posts into SQLite so they persist.
- Add edit and delete routes.
- Render content as Markdown; add comments.
- Add authentication and pagination.
Conclusion
Section titled “Conclusion”You built a Flask blog that lists, displays, and creates posts — and learned routing, forms, templating, and the PRG pattern along the way. Fixing the memory-storage flaw with SQLite and adding edit/delete turns it into a genuine little CMS. This is the foundation every Flask developer builds on. Full source on GitHub. Explore more web projects on Python Central Hub.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading