Simple Blog with Flask
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><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
- Python 3.6 or above.
- A text editor or IDE.
- Flask:
pip install flaskpip install flask. - Basic HTML.
- Understanding of functions, lists, and dicts.
Getting Started
Create the project
- Create a folder named
flask-blogflask-blogwith atemplates/templates/subfolder. - Inside it, create
simple_blog_with_flask.pysimple_blog_with_flask.py. - Install Flask:
pip install flaskpip install flask.
Templates
Flask renders HTML from the templates/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>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>{{ 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><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
simple_blog_with_flask.py
Source"""
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.
"""
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')
if __name__ == '__main__':
app.run(debug=True)
"""
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.
"""
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')
if __name__ == '__main__':
app.run(debug=True)
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.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.Step-by-Step Explanation
1. Listing posts
@app.route('/')
def index():
return render_template('index.html', posts=blog_posts)@app.route('/')
def index():
return render_template('index.html', posts=blog_posts)The home route passes the blog_postsblog_posts list into index.htmlindex.html, where Jinja loops over it. Data flows from Python to HTML through render_templaterender_template keyword arguments.
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@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><int:post_id> captures the number from /post/2/post/2 and passes it in. The next(...)next(...) finds the matching post; if none, returning a string + 404404 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)
@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')@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_idnew_id line generates the next ID.
The Real Problem: Posts Don’t Survive Restart
blog_postsblog_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()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()AUTOINCREMENTAUTOINCREMENT replaces the manual new_idnew_id, and ?? placeholders keep you safe from SQL injection. Now posts persist.
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'))@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
| Problem | Cause | Fix |
|---|---|---|
| Posts disappear on restart | Stored in a Python list | Persist to SQLite |
TemplateNotFoundTemplateNotFound | HTML not in templates/templates/ | Use the templates/templates/ folder |
400 Bad Request400 Bad Request on submit | request.form['x']request.form['x'] missing key | Match form name=name= attributes; use .get().get() |
| Refresh re-posts the entry | No redirect after POST | Redirect (PRG pattern) |
Crash on /post/999/post/999 | Assumed the post exists | Handle NoneNone → return 404 |
| HTML shows as raw tags | Auto-escaping confusion | Trust Jinja’s escaping; don’t disable it |
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 —
LIMITLIMIT/OFFSETOFFSETfor many posts. - Tags & search — categorize and filter posts.
- Base template — extract a shared layout with Jinja
{% extends %}{% extends %}.
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
- Routing — static and dynamic URLs.
- Forms — GET/POST,
request.formrequest.form, and the PRG pattern. - Templating — passing data to Jinja, loops,
url_forurl_for. - Persistence — moving from memory to a real database.
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
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.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
