Project: Server-Rendered Blog

Introduction

This project builds a personal blog with Flask and Jinja2—list posts, read details, and manage articles through simple CRUD views. You combine templates, SQLAlchemy, pagination, and optional Bootstrap styling into one app you can extend or deploy. It is the capstone for the server-rendered half of this track.

Prerequisites

Project Goals

  • Post model: title, slug, body, created_at, published flag
  • Public index with pagination
  • Post detail page by slug or id
  • Admin CRUD (login optional for learning—add Flask-Login for production)
  • SQLite locally; swap URI for MySQL on deploy

Step 1: Project Layout

Register blueprints:

python
from app.blog.routes import blog_bp
from app.admin.routes import admin_bp
 
app.register_blueprint(blog_bp)
app.register_blueprint(admin_bp, url_prefix="/admin")

Step 2: Post Model

app/models.py:

Generate slug from title in admin form (lowercase, hyphens)—or use python-slugify.

Initialize DB:

bash
flask db init
flask db migrate -m "Create posts"
flask db upgrade

Step 3: Public Routes

app/blog/routes.py:

templates/blog/index.html:

Step 4: Admin CRUD

forms.py in admin package:

python
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
 
class PostForm(FlaskForm):
    title = StringField("Title", validators=[DataRequired(), Length(max=200)])
    slug = StringField("Slug", validators=[DataRequired(), Length(max=220)])
    body = TextAreaField("Body", validators=[DataRequired()])
    published = BooleanField("Published")
    submit = SubmitField("Save")

Create / edit / delete routes with validate_on_submit(), flash(), and redirect to admin list.

Tip

Protect /admin in Production

Add @login_required and @admin_required from Flask-Login chapter before deploying.

Step 5: Bootstrap (Optional)

Link in base.html:

html
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">

Use grid and card for post list—keep custom CSS minimal in static/css/style.css.

Step 6: Markdown Body (Optional)

Install markdown:

bash
pip install markdown

Template filter:

python
import markdown
 
@app.template_filter("markdown")
def markdown_filter(text):
    return markdown.markdown(text, extensions=["fenced_code"])

Template:

html
<div>{{ post.body|markdown|safe }}</div>

Only use |safe on your own admin-written content—not public user HTML.

Step 7: Seed Sample Posts

Flask CLI command:

python
@app.cli.command("seed-posts")
def seed_posts():
    if Post.query.count() > 0:
        print("Posts exist, skipping")
        return
    posts = [
        Post(title="Hello Flask", slug="hello-flask", body="First post content."),
        Post(title="Jinja2 Tips", slug="jinja2-tips", body="Templates and inheritance."),
    ]
    db.session.add_all(posts)
    db.session.commit()
    print("Seeded 2 posts")
bash
flask seed-posts

Verification Checklist

  • / lists posts with pagination when >10 entries
  • /post/<slug> shows full body; unknown slug returns 404
  • Admin create/edit/delete updates database
  • CSRF token on admin forms
  • flask db migrate works after model changes

Deploy Notes

Swap SQLALCHEMY_DATABASE_URI to MySQL—see deploy chapter. Static CSS can be served by Nginx—serving static files.

FAQ

Slug collision on create?

Validate unique slug in form or append -2 automatically.

Draft posts on index?

Filter published=True on public routes only.

RSS feed?

Add /feed.xml route rendering XML template—extra credit.

Comments?

New Comment model with post_id foreign key—scope a follow-up project.

SEO meta tags?

Pass title and description blocks in base.html per post.

Tests?

Add pytest for index 200 and post 404—patterns in testing chapter.