Skip to content

Simple Blog with Flask

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.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Flask: pip install flask.
  • Basic HTML.
  • Understanding of functions, lists, and dicts.
  1. Create a folder named flask-blog with a templates/ subfolder.
  2. Inside it, create simple_blog_with_flask.py.
  3. Install Flask: pip install flask.

Flask renders HTML from the templates/ folder:

templates/index.html
<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>
templates/post.html
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<a href="{{ url_for('index') }}">Back</a>
templates/new_post.html
<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>
simple_blog_with_flask.py pch.viewSource
simple_blog_with_flask.py
"""
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)
command
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.

Running the file exactly as it ships takes 0.6 s and prints:

python simple_blog_with_flask.py
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.

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.

diagram Diagram mermaid
simple_blog_with_flask.py
@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.

simple_blog_with_flask.py
@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.

simple_blog_with_flask.py
@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:

db.py
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.

Complete the CRUD set:

crud.py
@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'))
ProblemCauseFix
Posts disappear on restartStored in a Python listPersist to SQLite
TemplateNotFoundHTML not in templates/Use the templates/ folder
400 Bad Request on submitrequest.form['x'] missing keyMatch form name= attributes; use .get()
Refresh re-posts the entryNo redirect after POSTRedirect (PRG pattern)
Crash on /post/999Assumed the post existsHandle None → return 404
HTML shows as raw tagsAuto-escaping confusionTrust Jinja’s escaping; don’t disable it
  1. SQLite persistence — the essential upgrade above.
  2. Edit & delete — full CRUD.
  3. Markdown posts — render content with a Markdown library.
  4. Comments — a second table linked to posts.
  5. Authentication — login so only you can post (see REST API with Authentication).
  6. PaginationLIMIT/OFFSET for many posts.
  7. Tags & search — categorize and filter posts.
  8. Base template — extract a shared layout with Jinja {% extends %}.
  • 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.
  • 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.
  • Move posts into SQLite so they persist.
  • Add edit and delete routes.
  • Render content as Markdown; add comments.
  • Add authentication and pagination.

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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading