A lightweight, drop-in Markdown CMS for FastAPI.
Moosey CMS transforms your FastAPI application into a content-driven website without the need for a database. It bridges the gap between static site generators and dynamic web servers, offering hot-reloading, intelligent caching, SEO management, and a powerful templating hierarchy.
Check out the /example for templating and content samples used to generate the images above.
- No Database Required: Content is managed via Markdown files with YAML Frontmatter.
- Intelligent Routing: URL paths automatically map to your content directory structure.
- Smart Templating: "Waterfall" inheritance logic (Singular/Plural) to automatically find the best layout for every page.
- Hot Reloading: Instant browser refresh when Content or Templates change (Development mode only).
- High Performance: Built-in caching (TTL-based) that auto-clears on file changes.
- SEO Ready: Automatic OpenGraph, Twitter Cards, JSON-LD, and Meta tags generation.
- Rich Markdown: Supports tables, emojis, task lists, and syntax highlighting out of the box.
- Jinja2 Power: Use Jinja2 logic directly inside your Markdown files (Securely Sandboxed).
uv add moosey-cmspip install moosey-cmsIntegrate Moosey CMS into your existing FastAPI app in just a few lines.
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from moosey_cms import init_cms
app = FastAPI()
# 1. Define your paths
BASE_DIR = Path(__file__).resolve().parent
CONTENT_DIR = BASE_DIR / "content"
TEMPLATES_DIR = BASE_DIR / "templates"
# 2. Mount static files (Optional, but recommended for CSS/Images)
app.mount("/static", StaticFiles(directory="static"), name="static")
# 3. Initialize the CMS
init_cms(
app,
host="localhost",
port=8000,
dirs={
"content": CONTENT_DIR,
"templates": TEMPLATES_DIR
},
mode="development", # Enables hot-reloading
site_data={
"name": "My Awesome Site",
"description": "A site built with Moosey CMS",
"author": "Jane Doe",
"keywords": ["fastapi", "cms", "python"],
"open_graph": {
"og_image": "/static/cover.jpg"
},
"social": {
"twitter": "https://x.com/myhandle",
"github": "https://github.com/myhandle"
}
}
)Moosey CMS relies on a convention-over-configuration file structure.
.
├── main.py
├── content/ <-- Your Markdown Files
│ ├── index.md <-- Homepage (/)
│ ├── about.md <-- About Page (/about)
│ └── blog/
│ ├── index.md <-- Blog Listing (/blog)
│ ├── post-1.md <-- Blog Post (/blog/post-1)
│ └── post-2.md
└── templates/
├── layout
├── base.html <-- Base layout
├── index.html <-- Home Page layout
├── page.html <-- Default fallback
├── blog.html <-- Layout for /blog (Listing)
└── post.html <-- Layout for /blog/post-1 (Single Item)
When a user visits a URL, Moosey CMS searches for templates in a specific cascading order. This allows you to set global defaults while retaining the ability to customize specific pages or sections.
Example Scenario:
A user visits /posts/post-1.
Directory Structure:
.
├── content/
│ └── posts/
│ ├── index.md <-- Required for the '/posts' listing page to work
│ ├── post-1.md <-- The article being requested
│ └── post-2.md
└── templates/
├── posts/
│ └── post-1.html <-- 1. Specific Override
├── post.html <-- 2. Singular (Item) Layout
├── posts.html <-- 3. Plural (Section) Layout
└── page.html <-- 4. Global Fallback
Resolution Order:
- Frontmatter Override: If
post-1.mdcontainstemplate: special.html, that template is used immediately. - Exact Match:
templates/posts/post-1.html. - Singular Parent:
templates/post.html(Perfect for generic blog posts). - Plural Parent:
templates/posts.html(Perfect for section indexes). - Fallback:
templates/page.html.
You can control routing, visibility, and layout directly from the Markdown file YAML frontmatter.
title: My Amazing Post
date: 2024-01-01
description: A short summary for SEO.| Key | Type | Description |
|---|---|---|
order |
int |
Sort order in sidebars. Lower numbers appear first. Default: 9999. |
nav_title |
str |
Short title to display in sidebars (if different from title). |
visible |
bool |
Set to false to hide from sidebars/menus (page remains accessible via URL). |
draft |
bool |
If true, the page is only visible in development mode. |
group |
str |
Group sidebar items under a heading (requires template support). |
| Key | Type | Description |
|---|---|---|
template |
str |
Force a specific template file (e.g., template: landing.html). |
external_link |
str |
The sidebar link will point to this external URL instead of the page itself. |
redirect |
str |
Alias for external_link. |
Example:
---
title: API Documentation
nav_title: API Docs
weight: 1
group: "Developer Tools"
external_link: "https://api.mysite.com"
---Moosey CMS comes packed with a comprehensive library of Jinja2 filters to help you format your data effortlessly.
| Filter | Usage | Output |
|---|---|---|
fancy_date |
{{ date | fancy_date }} |
13th Jan, 2026 at 6:00 PM |
short_date |
{{ date | short_date }} |
Jan 13, 2026 |
iso_date |
{{ date | iso_date }} |
2026-01-13 |
time_only |
{{ date | time_only }} |
6:00 PM |
relative_time |
{{ date | relative_time }} |
2 hours ago / yesterday |
| Filter | Usage | Output |
|---|---|---|
currency |
{{ 1234.5 | currency('USD') }} |
$1,234.50 |
compact_currency |
{{ 1500000 | compact_currency }} |
$1.5M |
currency_name |
{{ 'KES' | currency_name }} |
Kenyan Shilling |
number_format |
{{ 1000 | number_format }} |
1,000 |
percentage |
{{ 50.5 | percentage }} |
50.5% |
ordinal |
{{ 3 | ordinal }} |
3rd |
| Filter | Usage | Output |
|---|---|---|
country_flag |
{{ 'US' | country_flag }} |
🇺🇸 |
country_name |
{{ 'DE' | country_name }} |
Germany |
language_name |
{{ 'fr' | language_name }} |
French |
| Filter | Usage | Output |
|---|---|---|
truncate_words |
{{ text | truncate_words(10) }} |
Truncates text to 10 words... |
excerpt |
{{ text | excerpt(150) }} |
Smart excerpt breaking at sentences. |
read_time |
{{ content | read_time }} |
5 min read |
slugify |
{{ 'Hello World' | slugify }} |
hello-world |
title_case |
{{ 'a tale of two cities' | title_case }} |
A Tale of Two Cities |
smart_quotes |
{{ '"Hello"' | smart_quotes }} |
“Hello” |
| Filter | Usage | Output |
|---|---|---|
filesize |
{{ 1024 | filesize }} |
1.0 KB |
yesno |
{{ True | yesno }} |
Yes |
default_if_none |
{{ val | default_if_none('N/A') }} |
Returns default if None |
Read More On Filters and how to use some interesting ones such as stripping comments.
The init_cms function accepts the following parameters:
| Parameter | Type | Description |
|---|---|---|
app |
FastAPI |
Your FastAPI application instance. |
host |
str |
Server host (used for hot-reload script injection). |
port |
int |
Server port. |
dirs |
dict |
Dictionary containing content and templates Paths. |
mode |
str |
"development" (enables hot reload/no cache) or "production". |
site_data |
dict |
Global data (Name, Author, Social Links). |
Moosey CMS takes security seriously. We have implemented several layers of protection to ensure your site remains safe:
- Path Traversal Protection: All URL requests are securely resolved against the content root using strict
pathlibchecks. It is impossible to access files outside thecontentdirectory (e.g.,../../etc/passwd). - SSTI Sandbox: While we allow Jinja2 logic inside Markdown files, this is executed in a Sandboxed Environment. Dangerous attributes (like
__class__,__subclasses__) are stripped, preventing Remote Code Execution (RCE) attacks. - DoS Prevention: The Hot-Reload middleware includes size checks to prevent memory exhaustion attacks from large file uploads/downloads.
Security is an ongoing process. If you discover a vulnerability, bug, or potential risk, please open an issue on our GitHub repository immediately. We appreciate community feedback to keep Moosey secure for everyone.
This project is inspired by fastapi-blog by Daniel. Initially, I wanted to use fastapi-blog and it worked really well till I needed features like hot-reloading.
MIT License. Copyright (c) 2026 Anthony Mugendi.

