Build Your Own Forum with FastAPI: Step 10 - Categories
Emily Parker
Product Engineer · Leapcell

In the previous article, we added an image upload feature to our forum, enriching the content of our posts.
Currently, all posts are crowded onto the same homepage feed. As the forum's content grows, this becomes very disorganized. Users might only be interested in specific topics but are distracted by unrelated content.
To solve this, in this article, we will introduce a Categories feature. We will create various boards (e.g., "Technical Discussion," "General Chat"), allowing users to select a category when posting and to browse posts by category.
Step 1: Update Database Models
We need a new table categories to store category information, and we need to add a foreign key in the posts table to link it.
Open models.py, add the Category model, and update the Post model.
models.py (Updated)
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import TSVECTOR from database import Base # ... (User and Comment models remain unchanged) ... class Post(Base): __tablename__ = "posts" id = Column(Integer, primary_key=True, index=True) title = Column(String, index=True) content = Column(String) owner_id = Column(Integer, ForeignKey("users.id")) image_url = Column(String, nullable=True) # --- New field --- category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) # --------------- owner = relationship("User", back_populates="posts") comments = relationship("Comment", back_populates="post", cascade="all, delete-orphan") search_vector = Column(TSVECTOR, nullable=True) # --- New relationship --- category = relationship("Category", back_populates="posts") # --------------- class Category(Base): __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=True, index=True, nullable=False) description = Column(String, nullable=True) posts = relationship("Post", back_populates="category")
The main changes in this step are:
- Created a new 
Categorymodel. - Added 
category_idas a foreign key in thePostmodel. - Established a 
relationshipbetweenPostandCategory, allowing us to access category info viapost.categoryor all posts in a category viacategory.posts. 
Step 2: Update Database Table Structure
Next, we need to actually create this table in the database and modify the posts table.
Create the categories table
CREATE TABLE categories ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, description TEXT );
Create default categories
To make the categories usable immediately, let's manually create two:
INSERT INTO categories (name, description) VALUES ('Technical', 'Discuss FastAPI, Python, databases, and other technical topics'), ('General', 'Share daily life, hobbies, etc.');
Modify the posts table
-- Add category_id column ALTER TABLE posts ADD COLUMN category_id INTEGER; -- Add foreign key constraint ALTER TABLE posts ADD CONSTRAINT fk_category FOREIGN KEY(category_id) REFERENCES categories(id);
Handle existing posts
For existing data in the posts table, we need to assign them a default category_id.
-- Update all existing posts to the 'General Chat' category (assuming its id is 2) UPDATE posts SET category_id = 2 WHERE category_id IS NULL;
Set to Not Null
Finally, to ensure data integrity, we will set the category_id column to NOT NULL.
ALTER TABLE posts ALTER COLUMN category_id SET NOT NULL;
If your database was created using Leapcell,
you can execute these SQL statements directly in its web-based operation panel.

Step 3: Update Post Creation Logic
Now when users create a post, they must specify a category. We need to modify the create_post route to accept category_id.
main.py (Update create_post route)
# ... (Previous imports) ... @app.post("/api/posts") async def create_post( title: str = Form(...), content: str = Form(...), category_id: int = Form(...), # Added category_id image: Optional[UploadFile] = File(None), db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # ... (Other logic remains unchanged) ... # Include category_id when creating the Post object new_post = models.Post( title=title, content=content, owner_id=current_user.id, image_url=image_url, category_id=category_id # Save the category ID ) db.add(new_post) await db.commit() await db.refresh(new_post) # 3. Redirect to the category page after posting, not the homepage return RedirectResponse(url=f"/categories/{category_id}", status_code=status.HTTP_303_SEE_OTHER)
Step 4: Implement Browsing by Category
We will no longer use /posts as the only post list page. Instead, we'll create a new route GET /categories/{category_id} to display posts from a specific category.
We will also modify the existing GET /posts route to serve as an "All Posts" aggregation page.
main.py (Add/modify routes)
# ... (Previous imports) ... from sqlalchemy.orm import selectinload # ... (Dependencies, etc.) ... # Helper function: get all categories from the database async def get_all_categories(db: AsyncSession): result = await db.execute(select(models.Category).order_by(models.Category.id)) return result.scalars().all() @app.get("/posts", response_class=HTMLResponse) async def view_posts( request: Request, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # Query all categories (for navigation) categories = await get_all_categories(db) # Query all posts stmt = ( select(models.Post) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": None # Mark that we are not in any specific category }) # --- New Route --- @app.get("/categories/{category_id}", response_class=HTMLResponse) async def view_posts_by_category( request: Request, category_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # Query all categories (for navigation) categories = await get_all_categories(db) # Query the current category category_result = await db.execute(select(models.Category).where(models.Category.id == category_id)) current_category = category_result.scalar_one_or_none() if not current_category: raise HTTPException(status_code=404, detail="Category not found") # Query posts in this category stmt = ( select(models.Post) .where(models.Post.category_id == category_id) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) .order_by(desc(models.Post.id)) ) result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("posts.html", { "request": request, "posts": posts, "categories": categories, "current_user": current_user, "current_category": current_category # Pass the current category information }) # ... (Other routes) ... @app.get("/posts/{post_id}", response_class=HTMLResponse) async def view_post_detail( request: Request, post_id: int, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): # When querying a post, also preload category info result = await db.execute( select(models.Post) .where(models.Post.id == post_id) .options(selectinload(models.Post.owner), selectinload(models.Post.category)) ) post = result.scalar_one_or_none() # ... (Subsequent comment query logic, etc., remains unchanged) ...
We added a new route GET /categories/{category_id} which reuses the posts.html template but only passes in posts for that category. We also modified GET /posts and GET /posts/{post_id} to ensure they correctly load and pass category information.
Step 5: Update Frontend Templates
Finally, we need to update the templates to display category navigation, allow selecting a category when posting, and show which category a post belongs to.
templates/posts.html (Updated)
<!DOCTYPE html> <html> <head> <style> /* ... (Existing styles) ... */ .category-nav { margin-top: 20px; margin-bottom: 20px; } .category-nav a { margin-right: 15px; text-decoration: none; } .category-nav a.active { font-weight: bold; } .post-category { font-size: 0.9em; color: #888; } </style> </head> <body> <div class="category-nav"> <strong>Categories:</strong> <a href="/posts" class="{{ 'active' if not current_category else '' }}">All</a> {% for category in categories %} <a href="/categories/{{ category.id }}" class="{{ 'active' if current_category and current_category.id == category.id else '' }}"> {{ category.name }} </a> {% endfor %} </div> {% if current_user and not current_user.is_banned %} <h2> Post a new thread {% if current_category %}in {{ current_category.name }} {% endif %} </h2> <form action="/api/posts" method="post" enctype="multipart/form-data"> <input type="text" name="title" placeholder="Post Title" required /><br /> <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br /> <label for="category">Select Category:</label> <select name="category_id" id="category" required> {% for category in categories %} <option value="{{ category.id }}" {{ 'selected' if current_category and current_category.id == category.id else '' }}> {{ category.name }} </option> {% endfor %} </select> <br /><br /> <label for="image">Upload Image (Optional, JPEG/PNG/GIF):</label> <input type="file" name="image" id="image" accept="image/jpeg,image/png,image/gif" /> <br /><br /> <button type="submit">Post</button> </form> {% elif current_user and current_user.is_banned %} {% else %} {% endif %} <hr /> <h2> Post List - {{ current_category.name if current_category else "All Posts" }} </h2> {% for post in posts %} <div class="post-item"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username if post.owner else 'Unknown' }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> {% endfor %} </body> </html>
templates/post_detail.html (Updated)
On the post detail page, we also add the category information.
... <body> <div class="post-container"> <h1>{{ post.title }}</h1> <p>{{ post.content }}</p> <small> Author: {{ post.owner.username }} | Category: <a href="/categories/{{ post.category.id }}">{{ post.category.name }}</a> </small> </div> ...
Run and Verify
Restart your uvicorn server:
uvicorn main:app --reload
Visit http://127.0.0.1:8000/.
You will see the new category navigation bar at the top ("All," "Technical," "General").
The "Post a new thread" form now has a new required "Select Category" dropdown.

In the post list, each post will show which category it belongs to.

Try clicking a category (e.g., "General"). The page will redirect to /categories/2, displaying only posts from that category, and the "Select Category" dropdown in the form will default to "General."

Conclusion
By adding the Category model and updating our routes and templates, we have successfully implemented a categories feature for our forum. It is now more convenient for users to find and browse the forum's content.
