Building a dynamic blog with Flask and HTMX can be both fun and rewarding. This guide will take you through the entire process, focusing on making your blog interactive without the need for a complex single-page application (SPA) framework. By the end, youβll have a fully functional blog where users can create, read, update, and delete posts seamlessly.
1οΈβ£ If you are brand new to HTMX, check out this article! -> Comprehensive Guide to HTMX: Building Dynamic Web Applications with Ease
2οΈβ£ If you want to focus on barebones without any authentications or adding css framework, check out Part 1 of this tutorial for a simpler version of the app here -> Building a Dynamic Blog with Flask and HTMX
What Youβll Need
- Basic knowledge of HTML, CSS, and JavaScript
- Basic understanding of Python and Flask (or your preferred backend framework)
- Python and pip installed on your machine
π½ TL:DR You can find the complete source code here-> GitHub Repo π for Part 2 Tutorial
Step 1: Setting Up Your Environment
1.1 Install Flask
First things first, letβs set up our Flask environment. Open your terminal and create a virtual environment, then install Flask:
python -m venv venv
source venv/bin/activate # On Windows, use `venv\Scripts\activate`
pip install Flask Flask-SQLAlchemy Flask-Login Flask-Bcrypt
1.2 Install TailwindCSS
Next, letβs set up TailwindCSS. Youβll need Node.js and npm installed on your machine.
Install TailwindCSS:
npm install -D tailwindcss
npx tailwindcss init
Configure TailwindCSS by editing the tailwind.config.js file:
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
theme: {
extend: {},
},
plugins: [],
}
Create a TailwindCSS input file static/css/tailwind.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
Add a build script to your package.json:
"scripts": {
"build": "tailwindcss -i ./static/css/tailwind.css -o ./static/css/styles.css --watch"
}
Run the build script to generate your CSS:
npm run build
1.3 Create the Project Structure
Organize your project directory as follows:
blog_app/
βββ static/
β βββ css/
β β βββ tailwind.css
β βββ js/
β βββ scripts.js
βββ templates/
β βββ base.html
β βββ index.html
β βββ post.html
β βββ edit_post.html
β βββ login.html
β βββ register.html
β βββ post_snippet.html
βββ app.py
βββ models.py
Step 2: Create the Flask Backend
2.1 Define Models
In models.py, define a simple data model for blog posts and user authentication using SQLAlchemy:
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from flask_bcrypt import Bcrypt
db = SQLAlchemy()
bcrypt = Bcrypt()
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
email = db.Column(db.String(150), unique=True, nullable=False)
password = db.Column(db.String(150), nullable=False)
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
user = db.relationship('User', backref='posts')
2.2 Set Up Flask Application
Next, set up your Flask application in app.py:
from flask import Flask, render_template, request, redirect, url_for, flash
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from models import db, User, Post, bcrypt
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'your_secret_key'
db.init_app(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
with app.app_context():
db.create_all() # Create database tables
@app.route('/')
def index():
posts = Post.query.all()
return render_template('index.html', posts=posts)
@app.route('/post/<int:post_id>')
def post(post_id):
post = Post.query.get_or_404(post_id)
return render_template('post.html', post=post)
@app.route('/create', methods=['POST'])
@login_required
def create():
title = request.form['title']
content = request.form['content']
if not title or not content:
flash("Title and content cannot be empty", "danger")
return redirect(url_for('index'))
new_post = Post(title=title, content=content, user_id=current_user.id)
db.session.add(new_post)
db.session.commit()
return render_template('post_snippet.html', post=new_post)
@app.route('/edit/<int:post_id>', methods=['GET', 'POST'])
@login_required
def edit(post_id):
post = Post.query.get_or_404(post_id)
if request.method == 'POST':
if post.user_id != current_user.id:
flash("You are not authorized to edit this post", "danger")
return redirect(url_for('index'))
post.title = request.form['title']
post.content = request.form['content']
db.session.commit()
return redirect(url_for('post', post_id=post.id))
return render_template('edit_post.html', post=post)
@app.route('/delete/<int:post_id>', methods=['POST', 'DELETE'])
@login_required
def delete(post_id):
post = Post.query.get_or_404(post_id)
if post.user_id != current_user.id:
flash("You are not authorized to delete this post", "danger")
return redirect(url_for('index'))
db.session.delete(post)
db.session.commit()
return '<script>window.location.href = "{}";</script>'.format(url_for('index'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = User.query.filter_by(email=email).first()
if user and bcrypt.check_password_hash(user.password, password):
login_user(user)
return redirect(url_for('index'))
flash('Invalid email or password', 'danger')
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
email = request.form['email']
password = request.form['password']
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
new_user = User(username=username, email=email, password=hashed_password)
db.session.add(new_user)
db.session.commit()
flash('Account created successfully', 'success')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('index'))
if __name__ == '__main__':
app.run(debug=True)
Step 3: Create HTML Templates
3.1 Base Template
In templates/base.html, define the base HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog App</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<script src="{{ url_for('static', filename='js/scripts.js') }}" defer></script>
</head>
<body class="bg-gray-100">
<nav class="bg-gray-800 p-4 text-white">
<div class="container mx-auto">
<a href="{{ url_for('index') }}" class="mr-4">Home</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('logout') }}" class="mr-4">Logout</a>
{% else %}
<a href="{{ url_for('login') }}" class="mr-4">Login</a>
<a href="{{ url_for('register') }}">Register</a>
{% endif %}
</div>
</nav>
<div class="container mx-auto py-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} mb-4 p-4 border-l-4 border-{{ category }}-400 bg-{{ category }}-100 text-{{ category }}-700">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
3.2 Index Template
In templates/index.html, create the index page to list all posts:
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-bold text-center mb-8">Blog Posts</h1>
{% if current_user.is_authenticated %}
<form hx-post="{{ url_for('create') }}" hx-target="#posts" hx-swap="beforeend" method="post" class="mb-8 p-4 bg-white shadow-md rounded">
<input type="text" name="title" placeholder="Title" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<textarea name="content" placeholder="Content" required class="w-full p-2 mb-4 border border-gray-300 rounded"></textarea>
<button type="submit" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Create</button>
</form>
{% endif %}
<div id="posts" class="space-y-4">
{% for post in posts %}
{% include 'post_snippet.html' %}
{% endfor %}
</div>
{% endblock %}
3.3 Post Template
In templates/post.html, create the template for displaying a single post:
{% extends "base.html" %}
{% block content %}
<div id="post-{{ post.id }}" class="post bg-white p-8 shadow-md rounded">
<h1 class="text-2xl font-bold mb-4">{{ post.title }}</h1>
<p class="mb-4">{{ post.content }}</p>
{% if current_user.is_authenticated and post.user_id == current_user.id %}
<div class="post-buttons flex space-x-4">
<a href="{{ url_for('edit', post_id=post.id) }}"
class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Edit</a>
<form action="{{ url_for('delete', post_id=post.id) }}" hx-delete="{{ url_for('delete', post_id=post.id) }}"
hx-target="#post-{{ post.id }}" hx-swap="outerHTML" method="post"
class="delete-form inline-block">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600">Delete</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
3.4 Post Snippet Template
In templates/post_snippet.html, create a snippet for individual posts to be used for dynamic updates:
<div class="post bg-white p-4 shadow-md rounded" id="post-{{ post.id }}">
<h2 class="text-xl font-bold"><a href="{{ url_for('post', post_id=post.id) }}" class="hover:underline">{{ post.title }}</a></h2>
<p class="mb-4">{{ post.content }}</p>
{% if current_user.is_authenticated and post.user_id == current_user.id %}
<div class="post-buttons flex space-x-4">
<form action="{{ url_for('delete', post_id=post.id) }}" hx-delete="{{ url_for('delete', post_id=post.id) }}" hx-target="#post-{{ post.id }}" hx-swap="outerHTML" method="post" class="delete-form inline-block">
<a href="{{ url_for('edit', post_id=post.id) }}" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Edit</a>
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600">Delete</button>
</form>
</div>
{% endif %}
</div>
3.5 Edit Post Template
In templates/edit_post.html, create the template for editing a post:
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-bold text-center mb-8">Edit Post</h1>
<form method="post" class="mb-8 p-4 bg-white shadow-md rounded">
<input type="text" name="title" value="{{ post.title }}" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<textarea name="content" required class="w-full p-2 mb-4 border border-gray-300 rounded">{{ post.content }}</textarea>
<button type="submit" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Save</button>
</form>
{% endblock %}
3.6 Login Template
In templates/login.html, create the template for user login:
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-bold text-center mb-8">Login</h1>
<form method="post" class="mb-8 p-4 bg-white shadow-md rounded">
<input type="email" name="email" placeholder="Email" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<input type="password" name="password" placeholder="Password" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<button type="submit" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Login</button>
</form>
{% endblock %}
3.7 Register Template
In templates/register.html, create the template for user registration:
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-bold text-center mb-8">Register</h1>
<form method="post" class="mb-8 p-4 bg-white shadow-md rounded">
<input type="text" name="username" placeholder="Username" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<input type="email" name="email" placeholder="Email" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<input type="password" name="password" placeholder="Password" required class="w-full p-2 mb-4 border border-gray-300 rounded">
<button type="submit" class="bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600">Register</button>
</form>
{% endblock %}
π₯ Fired up to learn HTMX in more depth? This is a MUST read for leveling up. π
Hypermedia Systems Kindle Edition
Step 4: Add Enhanced Debugging for HTMX
Create a simple JavaScript file (scripts.js) to handle HTMX events for better debugging:
/* static/js/scripts.js */
document.addEventListener('htmx:afterRequest', (event) => {
console.log('HTMX request completed:', event.detail);
});
document.addEventListener('htmx:error', (event) => {
console.error('HTMX request error:', event.detail);
});
Step 5: Testing Your Application
Now that you have set up the backend, created the HTML templates, and added HTMX for interactivity, itβs time to test your application. Make sure your Flask server is running by using the command:
flask --debug run
Open your web browser and navigate to http://127.0.0.1:5000/. You should see your blogβs home page, where you can create, view, edit, and delete blog posts.
Create a Post
- Enter a title and content in the form at the top of the page.
- Click the βCreateβ button. The new post should appear instantly on the page without a full page reload.
View a Post
- Click on the title of a post to view its full content on a separate page.
Edit a Post
- Click the βEditβ link next to a post.
- Modify the title or content and click βSaveβ. You should be redirected to the updated postβs page.
- Click home on top to go back to home page.
Delete a Post
- Click the βDeleteβ button next to a post. The post should be removed instantly without a full page reload.
Conclusion
In this comprehensive tutorial, you have learned how to create a dynamic blog application using Flask, HTMX, and TailwindCSS. Hereβs a quick recap of what weβve covered:
- Setting up a Flask environment and project structure
- Creating and configuring a Flask application
- Defining models with SQLAlchemy
- Creating HTML templates for your blog
- Adding HTMX attributes for dynamic form submission and deletion
- Styling your application with TailwindCSS
- Adding user authentication and authorization
By following these steps, you can build modern web applications with enhanced interactivity without the need for complex single-page application frameworks. HTMX allows you to keep your workflow simple and productive while providing a smooth user experience.
Further Reading and Resources
To deepen your understanding and keep up with the latest trends and best practices in web development, here are some resources you might find helpful:
Hope you enjoyed a more robust version of the app. The sky is the limit continue to improve and even create a project template that could work for real-world project. Happy coding!
Top comments (2)
Great article. Thanks for the read!
Thanks appreciate it!