Build a blog from Markdown
Turn a folder of Markdown files into a blog with an index, post pages, drafts and an RSS feed.
Nitro doesn't have a blog feature, and it doesn't need one. A post is a Markdown file, a dynamic route turns each file into a page, and one small Python module ties them together. You stay in control of every piece.
By the end you'll have a post index at /blog, one page per post at /blog/<slug>, drafts that only show up while you develop, and an RSS feed.
What you'll build
src/
content/posts/ # one .md file per post
hello-world.md
lib/
__init__.py
posts.py # reads and parses the posts
components/
blog_layout.py # shared page shell
pages/
blog.py # /blog
blog/
[slug].py # /blog/<slug>
plugins/
rss.py # writes build/feed.xml
Install the Markdown extra
pip install "nitro-cli[markdown]"
The extra adds two libraries: markdown to turn Markdown into HTML, and python-frontmatter to read the settings block at the top of each post.
Write a post
Create src/content/posts/hello-world.md:
---
title: Hello, world
date: 2026-10-01
description: The first post on my new Nitro blog.
---
This blog is built with **Nitro**. Every post is a Markdown file.
```python
print("hello")
```
The block between the --- lines is front matter. These are the fields the code below reads:
| Field | Required | What it does |
|---|---|---|
title | Yes | Page title and heading |
date | Yes | Sort order and the date shown on the post, as YYYY-MM-DD |
description | No | Summary on the index page, meta description and feed |
draft | No | Set to true to hide the post from production builds |
The filename becomes the URL, so hello-world.md is served at /blog/hello-world.
Load posts in one place
Both pages and the feed need the same list of posts, so keep the loading code in one module. Nitro puts src/ on the import path while it builds, so anything in src/lib/ can be imported from a page.
Create an empty src/lib/__init__.py, then src/lib/posts.py:
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import frontmatter
import markdown
from nitro import env
POSTS_DIR = Path(__file__).resolve().parent.parent / "content" / "posts"
REQUIRED_FIELDS = ("title", "date")
@dataclass
class Post:
slug: str
title: str
date: date
description: str
html: str
def load_posts():
posts = []
for path in POSTS_DIR.glob("*.md"):
source = frontmatter.load(path)
missing = [field for field in REQUIRED_FIELDS if field not in source]
if missing:
raise ValueError(f"{path.name} is missing {', '.join(missing)}")
if source.get("draft") and env.is_production():
continue
posts.append(
Post(
slug=path.stem,
title=source["title"],
date=source["date"],
description=source.get("description", ""),
html=markdown.markdown(
source.content, extensions=["fenced_code", "tables"]
),
)
)
return sorted(posts, key=lambda post: post.date, reverse=True)
def get_post(slug):
return next(post for post in load_posts() if post.slug == slug)
def format_date(value):
return f"{value.day} {value:%B %Y}"
A few things this does for you:
- It fails loudly. A post without a
titleordatestops the build with a message naming the file, for examplebroken.md is missing title. - It hides drafts in production.
env.is_production()is true duringnitro buildand false undernitro dev, so you can preview a draft locally without publishing it. - It sorts newest first.
python-frontmatterreads2026-10-01as a realdate, so sorting works without any parsing.
A shared layout
Create src/components/blog_layout.py. Every blog page uses it, and the alternate link lets feed readers find your RSS feed.
from nitro_ui import HTML, Head, Body, Title, Meta, Link, Main, Header, Anchor
def blog_layout(title, *children, description=""):
return HTML(
Head(
Meta(charset="utf-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1"),
Title(title),
Meta(name="description", content=description),
Link(rel="alternate", type="application/rss+xml", href="/feed.xml"),
Link(rel="stylesheet", href="/assets/styles/main.css"),
),
Body(
Header(Anchor("My blog", href="/blog")),
Main(*children),
),
lang="en",
)
The blog index
Create src/pages/blog.py:
from nitro import Page
from nitro_ui import H1, Paragraph, Article, H2, Anchor, Time
from components.blog_layout import blog_layout
from lib.posts import load_posts, format_date
def post_summary(post):
return Article(
H2(Anchor(post.title, href=f"/blog/{post.slug}")),
Time(format_date(post.date), datetime=post.date.isoformat()),
Paragraph(post.description),
)
def render():
posts = load_posts()
return Page(
title="Blog",
content=blog_layout(
"Blog",
H1("Blog"),
*[post_summary(post) for post in posts],
description="Notes and posts.",
),
)
Keep the index at src/pages/blog.py rather than src/pages/blog/index.py. It builds to blog.html and is served at /blog, next to the post pages in blog/.
One page per post
Create src/pages/blog/[slug].py:
from nitro import Page
from nitro_ui import Article, H1, Time, Partial, Anchor, Paragraph
from components.blog_layout import blog_layout
from lib.posts import load_posts, get_post, format_date
def get_paths():
return [{"slug": post.slug} for post in load_posts()]
def render(slug):
post = get_post(slug)
return Page(
title=post.title,
content=blog_layout(
post.title,
Article(
H1(post.title),
Time(format_date(post.date), datetime=post.date.isoformat()),
Partial(post.html),
),
Paragraph(Anchor("All posts", href="/blog")),
description=post.description,
),
)
The square brackets make this a dynamic route. Nitro calls get_paths() once, then calls render() for each dict it returns and writes the result to build/blog/<slug>.html. The placeholder in the filename, the key in each dict and the parameter name on render() all have to match, so keep all three as slug.
Partial inserts the Markdown's HTML as is. Nitro UI escapes text by default, and without Partial your post would show up as literal tags.
Drafts
Add draft: true to a post's front matter and it shows up under nitro dev but not in nitro build.
The filtering happens in load_posts() rather than with Page(draft=True). The draft flag on Page doesn't apply to pages generated by a dynamic route, and filtering in one place also keeps drafts off the index page and out of the feed.
Add an RSS feed
Nitro pages always build to .html, so the feed comes from a plugin instead. The nitro.post_build hook runs once the site is built, and gets the build folder and your config.
Create src/plugins/rss.py:
import sys
from email.utils import format_datetime
from datetime import datetime, timezone
from pathlib import Path
from xml.sax.saxutils import escape
from nitro.plugins import NitroPlugin, hook
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lib.posts import load_posts # noqa: E402
class Plugin(NitroPlugin):
name = "rss"
version = "1.0.0"
@hook("nitro.post_build")
def write_feed(self, data):
base_url = data["config"].base_url.rstrip("/")
items = []
for post in load_posts()[:20]:
url = f"{base_url}/blog/{post.slug}"
published = datetime.combine(post.date, datetime.min.time(), timezone.utc)
items.append(
"<item>"
f"<title>{escape(post.title)}</title>"
f"<link>{url}</link>"
f"<guid>{url}</guid>"
f"<pubDate>{format_datetime(published)}</pubDate>"
f"<description>{escape(post.description)}</description>"
"</item>"
)
feed = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<rss version="2.0"><channel>'
f"<title>My blog</title><link>{base_url}/blog</link>"
"<description>Notes and posts.</description>"
+ "".join(items)
+ "</channel></rss>"
)
(Path(data["build_dir"]) / "feed.xml").write_text(feed, encoding="utf-8")
return data
Turn it on in nitro.config.py, and set base_url to your real domain, because feed links have to be absolute:
from nitro import Config
config = Config(
site_name="My blog",
base_url="https://example.com",
plugins=["rss"],
)
Plugins don't get src/ on the import path the way pages do, which is why rss.py adds it before importing load_posts.
If the feed is ever missing from a build, check the build output. Nitro ignores errors raised inside a plugin, so a broken feed plugin doesn't fail the build. The plugin guide covers how to make one stop it.
Things to watch for
Only render Markdown you trust
Partial skips Nitro UI's escaping, and Markdown allows raw HTML. That's fine for posts you write yourself. Don't feed it Markdown from other people without sanitising the HTML first.
Renaming a file changes its URL
The slug comes from the filename. If you rename a published post, its old URL stops working, so add a redirect on your host or leave the filename alone.
Syntax highlighting
The code above wraps code blocks in <pre><code class="language-python">, which works with any client-side highlighter. To highlight at build time instead, install pygments, add "codehilite" to the Markdown extensions and generate a stylesheet for it:
pip install pygments
pygmentize -S default -f html -a .codehilite > src/styles/pygments.css