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:

FieldRequiredWhat it does
titleYesPage title and heading
dateYesSort order and the date shown on the post, as YYYY-MM-DD
descriptionNoSummary on the index page, meta description and feed
draftNoSet 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 title or date stops the build with a message naming the file, for example broken.md is missing title.
  • It hides drafts in production. env.is_production() is true during nitro build and false under nitro dev, so you can preview a draft locally without publishing it.
  • It sorts newest first. python-frontmatter reads 2026-10-01 as a real date, 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