Building a blogging system in FastAPI
October 5, 2026
On this page (15 sections)
FastAPI Blog Devlog #1: Bootstrapping the App
I’ve started a FastAPI project to get more comfortable building backend applications with it.
Instead of working through isolated examples, I’m building a small blog from scratch. I want the same application to expose a JSON API and serve regular HTML pages, with both using the same underlying data.
The longer-term plan is authentication, CRUD operations, a real database, file uploads, background tasks, pagination, testing, and deployment. For now I’m keeping the scope smaller: get the application running, understand the routing model, separate the API from the browser-facing pages, and give the frontend a proper structure.
Initial setup#
I’m using uv to manage the project and its dependencies.
uv init fastAPI_blog
cd fastAPI_blog
uv add "fastapi[standard]"
The equivalent FastAPI install with pip would be:
pip install "fastapi[standard]"
The standard install includes FastAPI, Uvicorn, the FastAPI CLI, and Jinja2 support.
I started main.py with the minimum needed to create the app:
from fastapi import FastAPI
app = FastAPI()
Then I added a basic root route:
@app.get("/")
def home():
return {"message": "Hello World"}
Returning a normal Python dictionary is enough here. FastAPI serializes it to JSON for me.
I’m also sticking with regular synchronous functions for now:
def home():
...
FastAPI supports async def, but I’d rather introduce async when I have a reason to use it instead of making every route asynchronous from the start.
Running the app#
Since I’m using uv, I start the development server with:
uv run fastapi dev main.py
Without uv, the command would be:
fastapi dev main.py
The app runs locally on port 8000:
http://localhost:8000
fastapi dev watches the project files and reloads the server whenever I save a change, so the development loop is just edit, save, refresh.
There’s also:
fastapi run main.py
for running the application without the development behavior.
I hit one small VS Code issue during setup. FastAPI showed as an unresolved import even though it was installed correctly. VS Code had the wrong Python interpreter selected. Switching it to the project environment fixed it.
Automatic API docs#
FastAPI generates API documentation automatically.
The interactive Swagger UI is available at:
/docs
and there’s another view at:
/redoc
Routes appear there as I add them, and I can run requests directly from the browser. That gives me a quick way to test an endpoint before anything on the frontend depends on it.
Adding some temporary post data#
Before setting up a database, I added a small in-memory list of dictionaries to represent posts.
Each post has roughly this shape:
{
"id": ...,
"author": ...,
"title": ...,
"content": ...,
"date_posted": ...
}
It’s temporary, but it’s enough to start building routes against.
The first actual API route is:
@app.get("/api/posts")
def get_posts():
return posts
Since posts is a list of dictionaries, FastAPI serializes it into a JSON array automatically.
GET /api/posts
|
v
FastAPI
|
v
Python data
|
v
JSON
Separating API routes from page routes#
I also want the application to serve normal browser pages.
I initially tested that by returning raw HTML, but that stopped being practical almost immediately. I want the project split more like this:
FastAPI
|
+---------+---------+
| |
v v
Page routes API routes
| |
v v
HTML JSON
Both sides can use the same data, but they return different representations of it.
I’m keeping browser-facing routes out of the generated API docs with:
include_in_schema=False
That keeps /docs focused on the JSON API instead of filling it with regular site pages.
Moving the HTML into Jinja2#
I created a templates/ directory:
fastAPI_blog/
│
├── main.py
└── templates/
Then configured Jinja2:
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
The home route now renders an HTML file instead of building markup inside Python:
@app.get("/", include_in_schema=False)
@app.get("/posts", include_in_schema=False)
def home(request: Request):
return templates.TemplateResponse(
request=request,
name="home.html",
)
This gets the HTML out of main.py, which is enough reason to use templates even before the pages become complicated.
Passing data into the template#
The template needs the post data, so I pass it through the context:
return templates.TemplateResponse(
request=request,
name="home.html",
context={
"posts": posts,
"title": "Home",
},
)
That context dictionary is how data moves from Python into Jinja.
Jinja uses:
{{ ... }}
for values, and:
{% ... %}
for control flow.
Rendering all of the posts looks like this:
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
{% endfor %}
Even though each post is a dictionary, Jinja lets me write:
post.title
instead of:
post["title"]
I also used a conditional for the page title:
<title>
{% if title %}
FastAPI Blog - {{ title }}
{% else %}
FastAPI Blog
{% endif %}
</title>
That covers most of the Jinja syntax I need for now: values, loops, and conditionals.
Template inheritance#
Once the first template worked, copying the full HTML document into every new page clearly wasn’t going to work for long.
I added:
templates/layout.html
The layout contains the shared parts of the site: the <head>, navigation, footer, styles, and scripts.
Page-specific content goes into a Jinja block:
{% block content %}
{% endblock content %}
Then home.html only needs to extend the layout and provide that block:
{% extends "layout.html" %}
{% block content %}
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
{% endfor %}
{% endblock content %}
Now a change to the navigation or other shared markup only needs to happen in layout.html.
Styling and static files#
I expanded the layout with Bootstrap and some custom styling.
The frontend now has a navbar, main content area, sidebar, footer, custom CSS, fonts, icons, a default profile image, and a light/dark theme toggle.
That required a proper static directory:
fastAPI_blog/
│
├── main.py
│
├── templates/
│ ├── layout.html
│ └── home.html
│
└── static/
├── css/
│ └── main.css
├── js/
├── icons/
└── profile_pics/
The first time I loaded the styled version, the HTML appeared but the CSS and images didn’t.
The development server showed 404 responses for /static/.... The files were there, but FastAPI wasn’t serving the directory.
I fixed that by mounting it:
from fastapi.staticfiles import StaticFiles
app.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
A file such as:
static/css/main.css
is now available at:
/static/css/main.css
After that, the styling and assets loaded normally.
Using url_for() instead of hardcoded paths#
I also replaced hardcoded internal URLs with url_for().
For routes:
{{ url_for('home') }}
For static files:
{{ url_for('static', path='css/main.css') }}
This means the templates refer to route names instead of manually building URLs. If a path changes later, there are fewer hardcoded references to update.
A route naming issue#
Using url_for() exposed one small routing issue.
I had two paths attached to the same function:
@app.get("/")
@app.get("/posts")
def home(...):
...
FastAPI normally uses the function name as the route name, so both routes were associated with home.
Because of that:
{{ url_for('home') }}
was generating /posts instead of /.
Both routes worked, but I wanted the Home link to point to the root URL.
The fix was to give each route an explicit name:
@app.get(
"/",
include_in_schema=False,
name="home",
)
@app.get(
"/posts",
include_in_schema=False,
name="posts",
)
def home(request: Request):
...
Now url_for('home') resolves to /, while /posts has its own route name.
That’s something I’ll probably keep doing anywhere templates need to refer to routes directly.
What main.py roughly looks like now#
The core setup currently looks something like this:
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory="templates")
app.mount(
"/static",
StaticFiles(directory="static"),
name="static",
)
@app.get(
"/",
include_in_schema=False,
name="home",
)
@app.get(
"/posts",
include_in_schema=False,
name="posts",
)
def home(request: Request):
return templates.TemplateResponse(
request=request,
name="home.html",
context={
"posts": posts,
"title": "Home",
},
)
@app.get("/api/posts")
def get_posts():
return posts
The HTML and API sides still use the same post data, but the output is different:
GET /
|
v
Jinja2
|
v
HTML
GET /api/posts
|
v
Python data
|
v
JSON
Current state#
At this point I have:
- FastAPI running with
uv - automatic reload during development
- JSON endpoints
- generated API documentation
- temporary post data
- separate page and API routes
- Jinja2 templates
- template context
- loops and conditionals
- template inheritance
- Bootstrap and custom CSS
- static files
- URL generation with
url_for() - explicit route names where needed
- HTML routes hidden from
/docs
The same posts data can now be returned in two ways:
posts
|
+----> Jinja2 ----> HTML
|
+---------------> JSON
That keeps the backend shared without mixing the API and browser-facing routes together.
Things I want to remember#
A few details from this first pass are worth keeping around.
FastAPI will serialize normal dictionaries and lists into JSON.
I don’t need to use async everywhere just because the framework supports it.
Python data gets into Jinja through the template context:
context={
"posts": posts,
"title": "Home",
}
Template inheritance keeps shared HTML in one place instead of repeating it across every page.
Static files need to be mounted before FastAPI can serve them.
And named routes with url_for() are cleaner than hardcoding internal URLs into the templates.
Next#
Right now I can return and display the full list of posts, but I can’t address one specific post through the URL.
Next I want to add path parameters so I can support something like:
GET /api/posts/1
and build the equivalent browser page for a single post.
That should also introduce FastAPI’s parameter validation and the error handling needed when a requested post doesn’t exist.