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
- Jinja2 Templates
- Flask-SQLAlchemy and Models
- Database Migrations With Flask-Migrate
- Forms and Flask-WTF
- Blueprints and Application Factory
Project Goals
Postmodel: 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:
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:
flask db init
flask db migrate -m "Create posts"
flask db upgradeStep 3: Public Routes
app/blog/routes.py:
templates/blog/index.html:
Step 4: Admin CRUD
forms.py in admin package:
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:
<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:
pip install markdownTemplate filter:
import markdown
@app.template_filter("markdown")
def markdown_filter(text):
return markdown.markdown(text, extensions=["fenced_code"])Template:
<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:
@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")flask seed-postsVerification 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 migrateworks 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.