← Back to Tutorials
Python

Build a Blog CMS with Python Flask

Difficulty: Intermediate Est. Time: ~5 hours

Introduction

Content Management Systems (CMS) power millions of websites on the internet today. From WordPress to Ghost, CMS platforms allow users to create, manage, and publish content without needing to write code.

In this tutorial, you'll build your own Blog CMS from scratch using Python and Flask. By the end, you'll understand how the big CMS platforms work under the hood and have a fully functional blogging platform.

What You'll Build

A complete blog CMS with:

  • User authentication (register, login, logout)
  • Create, read, update, and delete blog posts
  • Markdown support for writing posts
  • Category and tag organization
  • A functional admin dashboard
  • Comment system

What is a CMS?

A Content Management System is software that helps users create and manage digital content. There are two main types:

Headless CMS

A headless CMS provides only the backend API and content management capabilities. The frontend is built separately using any technology. This is great for developers who want full control over their frontend.

Coupled CMS

A coupled CMS (like WordPress) includes both the backend and frontend in one package. This is what we'll be building - it's easier to set up and perfect for beginners.

Why Flask?

Flask is a lightweight Python web framework that's perfect for building CMS platforms. It's easy to learn, flexible, and has a great ecosystem of extensions. Plus, since you're using Python, you can easily add features like machine learning or data analysis to your blog later!

Project Overview

Our blog CMS will have two main components:

Public Area (for readers)

  • Homepage - lists all blog posts
  • Post page - displays individual blog posts
  • Category pages - shows posts by category
  • About page - information about the blog

Admin Area (for authors)

  • Dashboard - overview of posts and comments
  • Post editor - create and edit posts
  • Category management
  • Comment moderation

Technical Stack

  • Backend: Flask (Python)
  • Database: SQLite (simple file-based database)
  • ORM: SQLAlchemy (for database operations)
  • Authentication: Flask-Login
  • Frontend: HTML, CSS, Jinja2 templates

Prerequisites

Before starting this tutorial, make sure you have:

  • Python installed - Download from python.org (version 3.8 or higher)
  • Code editor - VS Code is recommended
  • Basic Python knowledge - Variables, functions, classes
  • Basic HTML/CSS understanding - For the frontend
Installing Python

If you're new to Python, download the latest version from python.org. During installation, make sure to check "Add Python to PATH" on Windows. You can verify installation by running python --version in your terminal.

Setting Up Flask

Let's set up our development environment and install the necessary packages.

Creating the Project

First, create a new folder for your project:

Bash
# Create project directory
mkdir flask-blog
cd flask-blog

# Create virtual environment (recommended)
python -m venv venv

# Activate virtual environment
# On Windows:
venv\Scripts\activate

# On Mac/Linux:
source venv/bin/activate

Installing Dependencies

Now let's install Flask and the extensions we need:

Bash
# Install Flask and extensions
pip install flask flask-sqlalchemy flask-login markdown

# What each package does:
# flask          - The web framework
# flask-sqlalchemy - ORM for database operations
# flask-login    - User authentication
# markdown       - Convert Markdown to HTML

Project Structure

Create the following folder structure:

File Structure
flask-blog/
├── app.py              # Main application file
├── config.py           # Configuration settings
├── models.py           # Database models
├── templates/          # HTML templates
│   ├── base.html       # Base layout
│   ├── index.html      # Homepage
│   ├── post.html       # Single post view
│   ├── login.html      # Login page
│   ├── register.html   # Registration page
│   └── admin/          # Admin templates
│       ├── dashboard.html
│       └── edit_post.html
├── static/             # CSS and images
│   └── style.css
└── venv/               # Virtual environment

Database Schema

A well-designed database is crucial for any CMS. Let's plan our schema:

Users Table

This table stores information about blog authors:

  • id - Unique identifier (integer)
  • username - Unique username (string)
  • email - User's email (string)
  • password_hash - Hashed password (string)
  • created_at - Account creation date (datetime)

Posts Table

This table stores blog posts:

  • id - Unique identifier (integer)
  • title - Post title (string)
  • slug - URL-friendly version of title (string)
  • content - Post content in Markdown (text)
  • published - Is the post published? (boolean)
  • created_at - Creation date (datetime)
  • updated_at - Last modified date (datetime)
  • author_id - Link to user who wrote it (foreign key)
  • category_id - Link to category (foreign key)

Categories Table

Organize posts into categories:

  • id - Unique identifier
  • name - Category name
  • slug - URL-friendly name
What is a Foreign Key?

A foreign key is a field in one table that links to the primary key of another table. For example, each post has an author_id that points to a user in the Users table. This is how we know who wrote each post!

Creating Database Models

Now let's create the database models in Flask-SQLAlchemy. Models define the structure of our database tables.

Python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash

# Initialize extensions
db = SQLAlchemy()
login_manager = LoginManager()

# Define User model
class User(db.Model, UserMixin):
    __tablename__ = 'users'
    
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(200), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    # Relationship to posts
    posts = db.relationship('Post', backref='author', lazy=True)
    
    # Set password (hashes it automatically)
    def set_password(self, password):
        self.password_hash = generate_password_hash(password)
    
    # Check password (verifies against hash)
    def check_password(self, password):
        return check_password_hash(self.password_hash, password)
    
    def __repr__(self):
        return f'<User ${self.username}>'

Now let's add the Post and Category models:

Python
# Define Category model
class Category(db.Model):
    __tablename__ = 'categories'
    
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), nullable=False)
    slug = db.Column(db.String(50), unique=True, nullable=False)
    
    # Relationship to posts
    posts = db.relationship('Post', backref='category', lazy=True)
    
    def __repr__(self):
        return f'<Category ${self.name}>'
Python
# Define Post model
class Post(db.Model):
    __tablename__ = 'posts'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    slug = db.Column(db.String(200), unique=True, nullable=False)
    content = db.Column(db.Text, nullable=False)
    published = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Foreign keys
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
    
    def __repr__(self):
        return f'<Post ${self.title}>'
Why Use Models?

Database models (like SQLAlchemy's) make it easy to work with databases. Instead of writing raw SQL queries, you can use Python objects and methods. For example, User.query.all() gets all users, and post.author.username gets the author's name.

Routes and Views

Routes determine what happens when a user visits a URL. Let's create the main routes for our blog.

Setting Up the App

First, let's create the main application file:

Python
from flask import Flask, render_template, redirect, url_for, flash, request
from models import db, User, Category, Post, login_manager
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.utils import secure_filename
import re

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-change-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'login'

# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

# Home page - shows all published posts
@app.route('/')
def index():
    posts = Post.query.filter_by(published=True).order_by(Post.created_at.desc()).all()
    return render_template('index.html', posts=posts)

# Single post page
@app.route('/post/<slug>')
def post(slug):
    post = Post.query.filter_by(slug=slug).first_or_404()
    return render_template('post.html', post=post)

Authentication Routes

Now let's add user registration and login:

Python
# Registration route
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        email = request.form['email']
        password = request.form['password']
        
        # Check if user already exists
        if User.query.filter_by(username=username).first():
            flash('Username already exists', 'error')
            return redirect(url_for('register'))
        
        # Check if email already exists
        if User.query.filter_by(email=email).first():
            flash('Email already registered', 'error')
            return redirect(url_for('register'))
        
        # Create new user
        user = User(username=username, email=email)
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        
        flash('Registration successful! Please login.', 'success')
        return redirect(url_for('login'))
    
    return render_template('register.html')

# Login route
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        
        user = User.query.filter_by(username=username).first()
        
        # Verify user exists and password is correct
        if user and user.check_password(password):
            login_user(user)
            return redirect(url_for('index'))
        else:
            flash('Invalid username or password', 'error')
    
    return render_template('login.html')

# Logout route
@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('index'))
Security Note

In production, never store plain-text passwords! We use werkzeug.security.generate_password_hash which creates a secure hash. Always use hashed passwords - if your database is compromised, attackers won't get actual passwords.

Creating Templates

Templates define how your pages look. Flask uses Jinja2, a powerful templating engine.

Base Template

Create a base template that all other templates will extend:

HTML
<!-- 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 Blog{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <nav>
        <a href="{{ url_for('index') }}">Home</a>
        {% if current_user.is_authenticated %}
            <a href="{{ url_for('dashboard') }}">Dashboard</a>
            <a href="{{ url_for('logout') }}">Logout</a>
        {% else %}
            <a href="{{ url_for('login') }}">Login</a>
            <a href="{{ url_for('register') }}">Register</a>
        {% endif %}
    </nav>

    <main>
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert {{ category }}">{{ message }}</div>
                {% endfor %}
            {% endif %}
        {% endwith %}
        
        {% block content %}{% endblock %}
    </main>

    <footer>
        <p>© 2026 My Blog</p>
    </footer>
</body>
</html>

Homepage Template

HTML
<!-- templates/index.html -->
{% extends "base.html" %}

{% block title %}Home - My Blog{% endblock %}

{% block content %}
    <h1>Latest Posts</h1>
    
    {% for post in posts %}
        <article class="post-preview">
            <h2><a href="{{ url_for('post', slug=post.slug) }}">{{ post.title }}</a></h2>
            <div class="post-meta">
                <span>By {{ post.author.username }}</span>
                <span>{{ post.created_at.strftime('%Y-%m-%d') }}</span>
                {% if post.category %}
                    <span class="category">{{ post.category.name }}</span>
                {% endif %}
            </div>
            <p>{{ post.content[:200] }}...</p>
            <a href="{{ url_for('post', slug=post.slug) }}" class="read-more">Read More</a>
        </article>
    {% else %}
        <p>No posts yet. <a href="{{ url_for('register') }}">Be the first to write one!</a></p>
    {% endfor %}
{% endblock %}
Understanding Jinja2

Jinja2 uses {{ }} for variables and {% %} for logic. The {% extends "base.html" %} tells Jinja2 to use base.html as the layout and fill in the {% block %} sections with our content.

Adding Markdown Support

Markdown allows bloggers to write content easily without HTML. Let's add markdown support to convert markdown to HTML when displaying posts.

Python
# Add this to your app.py or create a filters.py
import markdown

# Create a custom Jinja2 filter
def markdown_filter(text):
    # Convert markdown to HTML with some extensions
    return markdown.markdown(
        text,
        extensions=['extra', 'codehilite', 'toc']
    )

# Register the filter with Flask
@app.template_filter('markdown')
def markdown_filter(text):
    return markdown.markdown(text)

# Now in templates, use: {{ post.content|markdown }}

Update your post template to render markdown:

HTML
<!-- templates/post.html -->
{% extends "base.html" %}

{% block content %}
    <article class="full-post">
        <h1>{{ post.title }}</h1>
        
        <div class="post-meta">
            <span>By {{ post.author.username }}</span>
            <span>{{ post.created_at.strftime('%B %d, %Y') }}</span>
        </div>
        
        <!-- This renders markdown as HTML -->
        <div class="post-content">
            {{ post.content|markdown|safe }}
        </div>
    </article>
{% endblock %}
Writing in Markdown

With markdown support, you can write posts like this:

# Heading
## Subheading

This is **bold** and *italic* text.

- Bullet point 1
- Bullet point 2

```python
print("Code block!")
```

[Link text](https://example.com)

Admin Panel

The admin panel lets authors create and manage posts. Let's add the necessary routes.

Python
# Admin routes - add to app.py

# Dashboard - shows all user's posts
@app.route('/admin/dashboard')
@login_required
def dashboard():
    posts = Post.query.filter_by(author_id=current_user.id).all()
    return render_template('admin/dashboard.html', posts=posts)

# Create new post
@app.route('/admin/post/new', methods=['GET', 'POST'])
@login_required
def new_post():
    if request.method == 'POST':
        title = request.form['title']
        content = request.form['content']
        category_id = request.form['category_id']
        published = 'published' in request.form
        
        # Create URL-friendly slug
        slug = title.lower().replace(' ', '-')
        slug = re.sub(r'[^a-z0-9-]', '', slug)
        
        post = Post(
            title=title,
            slug=slug,
            content=content,
            category_id=category_id,
            published=published,
            author_id=current_user.id
        )
        
        db.session.add(post)
        db.session.commit()
        
        flash('Post created successfully!', 'success')
        return redirect(url_for('dashboard'))
    
    categories = Category.query.all()
    return render_template('admin/edit_post.html', categories=categories)

# Edit existing post
@app.route('/admin/post/<int:post_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_post(post_id):
    post = Post.query.get_or_404(post_id)
    
    # Only allow author to edit
    if post.author_id != current_user.id:
        abort(403)
    
    if request.method == 'POST':
        post.title = request.form['title']
        post.content = request.form['content']
        post.category_id = request.form['category_id']
        post.published = 'published' in request.form
        
        db.session.commit()
        flash('Post updated!', 'success')
        return redirect(url_for('dashboard'))
    
    categories = Category.query.all()
    return render_template('admin/edit_post.html', post=post, categories=categories)

# Delete post
@app.route('/admin/post/<int:post_id>/delete')
@login_required
def delete_post(post_id):
    post = Post.query.get_or_404(post_id)
    
    if post.author_id != current_user.id:
        abort(403)
    
    db.session.delete(post)
    db.session.commit()
    
    flash('Post deleted!', 'success')
    return redirect(url_for('dashboard'))
Authorization Check

Notice how we check if post.author_id != current_user.id before allowing edits or deletes. This is authorization - making sure users can only modify their own content. Without this, any logged-in user could edit anyone's posts!

Testing Your Blog

Now let's test our application to make sure everything works.

Initializing the Database

Python
# Create database tables
# Open Python shell:
python

# In Python shell:
from app import app, db
with app.app_context():
    db.create_all()
    print("Database created!")

# Exit Python shell
exit()

Running the Server

Bash
# Run the Flask development server
python app.py

# You should see output like:
# * Running on http://127.0.0.1:5000

Testing Steps

  1. Open browser to http://127.0.0.1:5000
  2. Click "Register" to create an account
  3. Log in with your new account
  4. Click "Dashboard" to access admin area
  5. Create a new post with some markdown
  6. View your post on the homepage
Testing Checklist
  • ✓ User can register and login
  • ✓ User can create new posts
  • ✓ Posts display markdown correctly
  • ✓ User can only edit their own posts
  • ✓ Categories work correctly
  • ✓ Published vs draft posts work

Deployment

When you're ready to share your blog with the world, you'll need to deploy it.

Deployment Platforms

  • Render - Free tier, good Python support
  • Railway - Simple deployment, excellent docs
  • PythonAnywhere - Designed specifically for Python apps
  • Heroku - Industry standard, requires credit card for free tier

Important Production Settings

Python
# config.py - Production configuration
import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard-to-guess-string'
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///blog.db'
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class ProductionConfig(Config):
    DEBUG = False

class DevelopmentConfig(Config):
    DEBUG = True

Using Environment Variables

Bash
# On Windows (PowerShell)
$env:SECRET_KEY = "your-secret-key-here"
$env:FLASK_ENV = "production"

# On Mac/Linux
export SECRET_KEY="your-secret-key-here"
export FLASK_ENV=production
Database for Production

SQLite works great for development but can have issues with high traffic. For production, consider using PostgreSQL (available free on Render and Railway). You'll just need to change the database URI!

Summary

Congratulations! You've built a complete Blog CMS from scratch. Let's review what you learned:

What You Built

  • User Authentication - Register, login, logout with secure password hashing
  • Database Models - Users, Posts, and Categories with relationships
  • Routes - Full CRUD operations for posts
  • Templates - Jinja2 templates with inheritance
  • Markdown - Convert Markdown to HTML for easy writing
  • Admin Panel - Dashboard for managing posts

Key Concepts Learned

  • Flask web framework basics
  • SQLAlchemy ORM
  • User authentication with Flask-Login
  • Template inheritance in Jinja2
  • RESTful route design
  • Security best practices (password hashing)

Next Steps

Here are some ideas for expanding your blog:

  • Add a comment system for readers
  • Implement tags for posts
  • Add a search feature
  • Create a static pages system (About, Contact)
  • Add image upload functionality
  • Implement a newsletter subscription
  • Add social media sharing buttons

Continue Learning

Ready for more? Try these tutorials:

  • Build a Weather Dashboard - Learn API integration
  • Build a URL Shortener - Learn database design
  • Build a Chat App - Learn real-time communication