Account

How to build a simple web application with Flask and HTMX

How to build a simple web application with Flask and HTMX


1. Learning Target & Outcome

Main Concept: Building a dynamic, single-page social media application using Flask for the backend and HTMX for frontend interactions - without writing any JavaScript!

Learning Objectives:

Understand how HTMX enables dynamic updates without full page reloads
Learn to structure Flask applications for HTMX integration
Master the pattern of returning HTML fragments instead of JSON
Build a complete CRUD-like application with real-time interactions

Final Outcome:

A working social wall where users can create posts and comments that appear instantly without page refreshes.


2. Core Components Breakdown

Let's break down the application into these core components:
  • Flask Backend Setup - Basic server configuration
  • In-Memory Database - Simple data storage simulation
  • User Management - Mock authentication system
  • HTML Fragment Rendering - The key to HTMX success
  • Post Creation System - First HTMX interaction
  • Comment System - Nested HTMX interactions
  • Full Page Assembly - Putting it all together


3. Step-by-Step Application Building

Step 1: Basic Flask Setup & Project Structure

from flask import Flask, render_template, request, jsonify, redirect, url_for
import time
import json
import uuid

app = Flask(__name__)
app.config['TEMPLATES_AUTO_RELOAD'] = True

if __name__ == "__main__":
    app.run(debug=True)


Explanation:
- Flask imports: We import all necessary Flask components
- Configuration: TEMPLATES_AUTO_RELOAD=True ensures templates refresh during development
- Debug mode: debug=True enables auto-reloading and better error messages

Why this structure? This is the minimal Flask application that can run. We'll build upon this foundation.


Step 2: In-Memory Database & User Management

# --- GLOBAL VARIABLES & IN-MEMORY "DATABASE" ---

# Simple in-memory dictionaries to mimic a database
POSTS = {}
USERS = {}

def get_current_user_id():
    """Simulates getting a user ID. In a real Flask app, this would come from session/JWT."""
    # We use a mock ID that can be uniquely associated with a temporary user name
    mock_id = request.headers.get('X-Mock-User-Id', 'guest_1234')
    if mock_id not in USERS:
        # Create a user name for display purposes
        USERS[mock_id] = f"User-{uuid.uuid4().hex[:6].upper()}"
    return mock_id, USERS[mock_id]


Explanation:
- POSTS & USERS Dictionaries
- Purpose: Simulate a database without complex setup
- Structure:
    POSTS: {postid: {userid, authorname, content, timestamp, comments: []}}
    USERS: {user
id: display_name}

Why in-memory?: For learning purposes - data resets on server restart

get_current_user_id() Function:
- Authentication Simulation: In production, you'd use Flask-Login, sessions, or JWT
- Mock User ID: Uses request headers to simulate different users
- Automatic User Creation: Creates a display name for new users
- Return Value: Returns both userid (for data association) and username (for display)

Why this approach? It separates concerns - user management is handled consistently throughout the app.


Step 3: HTML Fragment Rendering System

This is the CORE CONCEPT of HTMX integration:

def render_post_card(post_id, post_data, create_comment_url):
    """Renders a single post card HTML fragment using Bootstrap classes."""
    comments_html = "".join([render_comment(c) for c in post_data.get('comments', [])])

    return f"""
    <div id="post-{post_id}" class="card shadow mb-4">
        <div class="card-body p-4">
            <p class="text-muted small mb-2">Posted by <span class="fw-semibold text-primary">{post_data['author_name']}</span> @ {time.strftime('%H:%M:%S', time.localtime(post_data['timestamp']))}</p>
            <div class="bg-light p-3 rounded mb-4 border">
                <p class="card-text text-dark mb-0" style="white-space: pre-wrap;">{post_data['content']}</p>
            </div>

            <!-- Comment Section -->
            <div id="comments-list-{post_id}" class="mt-4 pt-4 border-top vstack gap-3">
                {comments_html}
            </div>

            <!-- New Comment Form -->
            <form hx-post="{create_comment_url}" 
                  hx-target="#comments-list-{post_id}" 
                  hx-swap="beforeend" 
                  class="mt-4 d-flex">

                <input type="hidden" name="post_id" value="{post_id}">
                <input type="text" name="comment_content" placeholder="Add a comment..." required 
                       class="form-control me-2" style="flex-grow: 1;">
                <button type="submit" class="btn btn-primary shadow-sm">
                    Comment
                </button>
            </form>
        </div>
    </div>
    """

def render_comment(comment_data):
    """Renders a single comment HTML fragment using Bootstrap classes."""
    return f"""
    <div class="card card-body bg-light p-3 rounded shadow-sm">
        <p class="mb-1 text-muted small"><span class="fw-semibold text-primary">{comment_data['author_name']}</span> @ {time.strftime('%H:%M', time.localtime(comment_data['timestamp']))}</p>
        <p class="mb-0 text-dark" style="white-space: pre-wrap;">{comment_data['content']}</p>
    </div>
    """


Deep Explanation:

HTML Fragment Philosophy:
- Traditional Approach: Return JSON, use JavaScript to update DOM
- HTMX Approach: Return HTML fragments, let HTMX handle DOM updates
- Benefit: Server controls the presentation, less client-side logic

HTMX Attributes Breakdown:
- hx-post: Where to send the POST request
- hx-target: Which element to update with the response
- hx-swap: How to insert the response (beforeend = append as last child)

Why separate render functions?
- Reusability: Same rendering logic for initial page load and HTMX updates
- Consistency: Posts/comments look the same whether loaded initially or added dynamically
- Maintainability: Change rendering in one place


Step 4: Basic Routes and Full Page Rendering

def render_main_page(current_user_name, create_post_url, create_comment_url_template):
    """Renders the full HTML structure with Bootstrap."""

    # Sort posts by timestamp (newest first)
    sorted_posts = sorted(POSTS.items(), key=lambda item: item[1]['timestamp'], reverse=True)

    # Pass the comment URL template into the post card renderer
    posts_html = "".join([render_post_card(post_id, post_data, create_comment_url_template.format(post_id=post_id)) for post_id, post_data in sorted_posts])

    return f"""
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Social Wall (Flask+HTMX)</title>
    <link href="/static/css/bootstrap.min.css" rel="stylesheet">
    <script src="/static/js/htmx.min.js"></script>
    <script src="/static/js/bootstrap.bundle.min.js"></script>
    <style>
        body {{ background-color: #f8f9fa; }}
    </style>
</head>
<body class="p-3">
    <div class="container-md mt-4">
        <header class="pb-3 mb-4 border-bottom border-4 border-primary">
            <h1 class="display-6 fw-bold text-dark">The HTMX Wall</h1>
            <p class="text-secondary small">You are logged in as: <span id="userIdDisplay" class="fw-bold text-primary">{current_user_name}</span></p>
        </header>

        <!-- Post Creation Form -->
        <form hx-post="{create_post_url}" 
              hx-target="#posts-container" 
              hx-swap="afterbegin" 
              class="card shadow-lg p-4 mb-4 border border-primary-subtle">
            <h2 class="h5 card-title mb-3 text-dark">What's on your mind?</h2>
            <textarea name="content" placeholder="Write your post here..." required 
                      class="form-control mb-3"
                      rows="3"></textarea>

            <div class="d-flex justify-content-between align-items-center">
                <button type="submit" class="btn btn-primary btn-lg shadow">
                    Post to Wall
                </button>
            </div>
            <!-- HTMX swap for form reset -->
            <div hx-on::after-request="this.reset()"></div>
        </form>

        <!-- Post Feed Container -->
        <div id="posts-container" class="vstack gap-4">
            {posts_html}
        </div>
    </div>
</body>
</html>
    """

@app.route("/")
def index():
    user_id, user_name = get_current_user_id()

    create_post_url = url_for('create_post')
    create_comment_url_template = url_for('create_comment', post_id="{post_id}") 

    # Pass the calculated URLs to the renderer
    return render_main_page(user_name, create_post_url, create_comment_url_template)


Explanation:

render_main_page() Function:
- Template Generation: Instead of separate template files, we use Python f-strings
- URL Generation: Uses Flask's url_for() to create proper URLs
- Post Sorting: Ensures newest posts appear first
- HTMX Integration: The entire page is designed around HTMX from the start

HTMX Form Features:
- hx-swap="afterbegin": New posts appear at the top
- hx-on::after-request="this.reset()": Clears form after successful submission
- Note: The hx-on attribute resets the parent form, not the div itself

Why generate HTML in Python?
- Simplicity: For learning, it keeps everything in one file
- Clarity: Easier to see the connection between backend and frontend
- Production Note: In real apps, you'd use Jinja2 templates


Step 5: Post Creation Endpoint

@app.route("/posts/create/", methods=["POST"])
def create_post():
    """Handles new post creation via HTMX."""
    content = request.form.get("content")
    if not content:
        return "", 400  # Bad request if content is missing

    user_id, user_name = get_current_user_id()
    post_id = str(uuid.uuid4())

    # Simulate DB insertion
    new_post = {
        "user_id": user_id,
        "author_name": user_name,
        "content": content,
        "timestamp": int(time.time()),
        "comments": []
    }
    POSTS[post_id] = new_post

    # Get the URL for comment creation specifically for the new post
    create_comment_url = url_for('create_comment', post_id=post_id)

    # Return the HTML fragment for the new post, which HTMX will insert "afterbegin"
    return render_post_card(post_id, new_post, create_comment_url)


Explanation:

HTMX Request Flow:
1. User submits form → HTMX intercepts and sends POST request
2. Flask processes request, creates post in "database"
3. Flask returns HTML fragment of the new post
4. HTMX receives response and inserts it into #posts-container

Key Design Decisions:
- Return HTML, not JSON: This is the HTMX way!
- HTTP Status Codes: Return 400 for bad requests
- URL Generation: Generate comment URL dynamically for each post
- UUID for IDs: Ensures unique identifiers

Why this pattern? It's incredibly efficient - the server does the rendering work once, and the client just inserts the ready-made HTML.

Step 6: Comment Creation System

@app.route("/posts/<post_id>/comments/create/", methods=["POST"])
def create_comment(post_id):
    """Handles comment creation via HTMX."""
    comment_content = request.form.get("comment_content")
    if not comment_content or post_id not in POSTS:
        return "", 400

    user_id, user_name = get_current_user_id()

    new_comment = {
        "user_id": user_id,
        "author_name": user_name,
        "content": comment_content,
        "timestamp": int(time.time())
    }

    # Simulate DB update
    POSTS[post_id]['comments'].append(new_comment)

    # Return ONLY the HTML fragment for the new comment.
    # HTMX will insert this "beforeend" into the comments-list target.
    return render_comment(new_comment)


Explanation:

Nested HTMX Interactions:
- Each post has its own comment form with its own HTMX attributes
- The form targets #comments-list-{post_id} - specific to that post
- hx-swap="beforeend" appends new comments to the bottom of the list

Form Structure:

<form hx-post="/posts/123/comments/create/" 
      hx-target="#comments-list-123" 
      hx-swap="beforeend">
    <input type="hidden" name="post_id" value="123">
    <!-- rest of form -->
</form>


Why separate comment endpoints per post?
- RESTful Design: Clear URL structure
- Security: Server validates post existence
- Scalability: Easy to add post-specific logic


4. Complete Application Assembly

from flask import Flask, render_template, request, jsonify, redirect, url_for
import time
import json
import uuid

# --- GLOBAL VARIABLES & IN-MEMORY "DATABASE" ---

# A simple in-memory dictionary to mimic a database.
# In a real app, this would be replaced with Firestore, PostgreSQL, or Redis.
# Structure: {post_id: {user_id: str, author_name: str, content: str, timestamp: int, comments: []}}
POSTS = {}
USERS = {} # Simple map of temporary ID to "User <ID>"

app = Flask(__name__)

app.config['TEMPLATES_AUTO_RELOAD'] = True

def get_current_user_id():
    """Simulates getting a user ID. In a real Flask app, this would come from session/JWT."""
    # We use a mock ID that can be uniquely associated with a temporary user name.
    mock_id = request.headers.get('X-Mock-User-Id', 'guest_1234')
    if mock_id not in USERS:
        # Create a user name for display purposes
        USERS[mock_id] = f"User-{uuid.uuid4().hex[:6].upper()}"
    return mock_id, USERS[mock_id]

# --- HTML FRAGMENT RENDERING HELPER ---

def render_post_card(post_id, post_data, create_comment_url):
    """Renders a single post card HTML fragment using Bootstrap classes."""
    # This is the power of HTMX: Flask returns only the HTML component it wants to update.
    comments_html = "".join([render_comment(c) for c in post_data.get('comments', [])])

    # We embed HTMX attributes like hx-post, hx-target, hx-swap, hx-trigger right into the HTML.
    return f"""
    <div id="post-{post_id}" class="card shadow mb-4">
        <div class="card-body p-4">
            <p class="text-muted small mb-2">Posted by <span class="fw-semibold text-primary">{post_data['author_name']}</span> @ {time.strftime('%H:%M:%S', time.localtime(post_data['timestamp']))}</p>
            <div class="bg-light p-3 rounded mb-4 border">
                <p class="card-text text-dark mb-0" style="white-space: pre-wrap;">{post_data['content']}</p>
            </div>

            <!-- Comment Section -->
            <div id="comments-list-{post_id}" class="mt-4 pt-4 border-top vstack gap-3">
                {comments_html}
            </div>

            <!-- New Comment Form -->
            <!-- NOTE: If you are running this code in Web Development Sandbox on Bytestark the path needs to be /proxy/5000{create_comment_url} as the VSCode server runs behind a proxy, otherwise remove it. -->
            <form hx-post="{create_comment_url}" 
                  hx-target="#comments-list-{post_id}" 
                  hx-swap="beforeend" 
                  class="mt-4 d-flex">

                <input type="hidden" name="post_id" value="{post_id}">
                <input type="text" name="comment_content" placeholder="Add a comment..." required 
                       class="form-control me-2" style="flex-grow: 1;">
                <button type="submit" class="btn btn-primary shadow-sm">
                    Comment
                </button>
            </form>
        </div>
    </div>
    """

def render_comment(comment_data):
    """Renders a single comment HTML fragment using Bootstrap classes."""
    return f"""
    <div class="card card-body bg-light p-3 rounded shadow-sm">
        <p class="mb-1 text-muted small"><span class="fw-semibold text-primary">{comment_data['author_name']}</span> @ {time.strftime('%H:%M', time.localtime(comment_data['timestamp']))}</p>
        <p class="mb-0 text-dark" style="white-space: pre-wrap;">{comment_data['content']}</p>
    </div>
    """

def render_main_page(current_user_name, create_post_url, create_comment_url_template):
    """Renders the full HTML structure with Bootstrap."""

    # Sort posts by timestamp (newest first)
    sorted_posts = sorted(POSTS.items(), key=lambda item: item[1]['timestamp'], reverse=True)

    # Pass the comment URL template into the post card renderer
    posts_html = "".join([render_post_card(post_id, post_data, create_comment_url_template.format(post_id=post_id)) for post_id, post_data in sorted_posts])

    return f"""
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Social Wall (Flask+HTMX)</title>
    <!-- Bootstrap 5 CSS -->
    <!-- NOTE: If you are running this code outside of Web Development Sandbox on Bytestark the path needs to be set accordingly. -->
    <link href="/proxy/5000/static/css/bootstrap.min.css" rel="stylesheet">
    <!-- HTMX -->
    <!-- NOTE: If you are running this code outside of Web Development Sandbox on Bytestark the path needs to be set accordingly. -->
    <script src="/proxy/5000/static/js/htmx.min.js"></script>
    <!-- Bootstrap 5 JS Bundle (required for some components, included for completeness) -->
    <!-- NOTE: If you are running this code outside of Web Development Sandbox on Bytestark the path needs to be set accordingly. -->
    <script src="/proxy/5000/static/js/bootstrap.bundle.min.js"></script>
    <style>
        body {{ background-color: #f8f9fa; }} /* Bootstrap light gray background */
    </style>
</head>
<body class="p-3">
    <div class="container-md mt-4">
        <header class="pb-3 mb-4 border-bottom border-4 border-primary">
            <h1 class="display-6 fw-bold text-dark">The HTMX Wall</h1>
            <p class="text-secondary small">You are logged in as: <span id="userIdDisplay" class="fw-bold text-primary">{current_user_name}</span></p>
            <p class="text-danger small mt-1">NOTE: This wall uses an **in-memory database in Flask**. Data will reset if the server restarts.</p>
        </header>

        <!-- Post Creation Form -->
        <!-- NOTE: If you are running this code in Web Development Sandbox on Bytestark the path needs to be /proxy/5000{create_post_url} as the VSCode server runs behind a proxy, otherwise remove it. -->
        <form hx-post="/proxy/5000{create_post_url}" 
              hx-target="#posts-container" 
              hx-swap="afterbegin" 
              class="card shadow-lg p-4 mb-4 border border-primary-subtle">
            <h2 class="h5 card-title mb-3 text-dark">What's on your mind?</h2>
            <textarea name="content" placeholder="Write your post here..." required 
                      class="form-control mb-3"
                      rows="3"></textarea>

            <div class="d-flex justify-content-between align-items-center">
                <button type="submit" class="btn btn-primary btn-lg shadow">
                    Post to Wall
                </button>
            </div>
            <!-- HTMX swap for form reset -->
            <div hx-on::after-request="this.reset()"></div>
        </form>

        <!-- Post Feed Container -->
        <div id="posts-container" class="vstack gap-4">
            {posts_html}
        </div>
    </div>
</body>
</html>
    """

# --- FLASK ROUTES ---

@app.route("/")
def index():
    user_id, user_name = get_current_user_id()

    create_post_url = url_for('create_post')
    create_comment_url_template = url_for('create_comment', post_id="{post_id}") 

    # Pass the calculated URLs to the renderer
    return render_main_page(user_name, create_post_url, create_comment_url_template)

@app.route("/posts/create/", methods=["POST"])
def create_post():
    """Handles new post creation via HTMX."""
    content = request.form.get("content")
    if not content:
        return "", 400 # Bad request if content is missing

    user_id, user_name = get_current_user_id()
    post_id = str(uuid.uuid4())

    # Simulate DB insertion
    new_post = {
        "user_id": user_id,
        "author_name": user_name,
        "content": content,
        "timestamp": int(time.time()),
        "comments": []
    }
    POSTS[post_id] = new_post

    # Get the URL for comment creation specifically for the new post
    create_comment_url = url_for('create_comment', post_id=post_id)

    # Return the HTML fragment for the new post, which HTMX will insert "afterbegin"
    return render_post_card(post_id, new_post, create_comment_url)

@app.route("/posts/<post_id>/comments/create/", methods=["POST"])
def create_comment(post_id):
    """Handles comment creation via HTMX."""
    comment_content = request.form.get("comment_content")
    if not comment_content or post_id not in POSTS:
        return "", 400

    user_id, user_name = get_current_user_id()

    new_comment = {
        "user_id": user_id,
        "author_name": user_name,
        "content": comment_content,
        "timestamp": int(time.time())
    }

    # Simulate DB update
    POSTS[post_id]['comments'].append(new_comment)

    # Return ONLY the HTML fragment for the new comment.
    # HTMX will insert this "beforeend" into the comments-list target.
    return render_comment(new_comment)

# Example usage if running locally:
if __name__ == "__main__":
    # If running locally without the proxy, you might comment out the APPLICATION_ROOT line above.
    app.run(debug=True)


Key Learning Takeaways

HTMX Philosophy

  • Server-Rendered HTML: Return HTML fragments, not JSON
  • Progressive Enhancement: Start with working HTML, enhance with HTMX
  • Declarative Approach: Describe what you want in HTML attributes

Flask + HTMX Patterns

  1. One render function per component - used for both initial render and updates
  2. Targeted updates - update only what changed
  3. Form handling - let HTMX handle form submission and response
  4. URL design - RESTful URLs that make sense for your actions

Next Steps for Enhancement

  • Add user authentication with Flask-Login
  • Replace in-memory storage with a real database
  • Add real-time features with WebSockets
  • Implement post editing and deletion
  • Add file uploads for images

This tutorial demonstrates how HTMX can dramatically simplify frontend development while keeping your Flask backend in control of the presentation logic. The pattern of returning HTML fragments instead of JSON is powerful and can be applied to many types of dynamic web applications.


You need to be logged in to access the cloud lab and experiment with the code presented in this tutorial.

Log in