Getting started: build a blog with rustango-cms
This walkthrough takes you from an empty directory to a working blog with an editor's admin: a page tree, two page types, a Markdown body stored in its own database table, and public pages served from templates you own. No database server to install — everything here runs on SQLite.
Time: about 20 minutes, most of it waiting for the first compile.
Runnable version: every step below produces the project in
examples/cms_demo. If something looks off, diff against it.
What you need first
| Tool | Why | Install |
|---|---|---|
| Rust 1.88+ | Compiler | https://rustup.rs |
A checkout of rustango-cms | The scaffolder, rcms | git clone https://github.com/ujeenet/rustango-cms |
The generated project gets rustango-cms and the rustango framework from crates.io; the checkout is only needed to run the scaffolder.
Check what you have:
rustc --version # should print 1.88 or newer
Step 1: Generate the project
The scaffolder is rcms: it generates a new site project. Run it straight from the repo:
cd ~/projects/rustango-cms
cargo run -p rcms-scaffold -- new myblog --dir ~/projects --template blog
--template blog gives you a home page plus an article type with a real Markdown body. (The other option, --template minimal, gives you the same two page types with no typed fields — useful when you want to design your own from scratch.)
It prints exactly what to run next:
Created rustango-cms project `myblog` (blog template) at /Users/you/projects/myblog
Next:
cd /Users/you/projects/myblog
cargo run -- makemigrations # emits cms_article_page
cargo run -- quickstart
Every command in this guide is the same in bash, zsh and Windows PowerShell. The project carries its database location in a .env file and builds for SQLite by default, so there is nothing to export and no flags to pass.
On Windows use
$HOMEinstead of~in paths:--dir $HOME\projects. PowerShell does not always expand~, and you would get a folder literally named~.
Here's what it wrote:
myblog/
├── Cargo.toml # depends on rustango-cms + rustango
├── .env # DATABASE_URL for the local SQLite database
├── .env.example # every setting, documented
├── .gitignore
├── README.md # the same commands, kept for later
├── migrations/ # the CMS baseline — don't hand-edit these
├── src/
│ ├── main.rs # wires the admin + public router together
│ ├── models.rs # your page types
│ └── quickstart.rs # the one-command local setup
└── templates/
├── _site.css.html # starter styles, included by both templates
├── home_page.html
└── article_page.html
Two files are yours to change; the rest you can mostly ignore at first. src/models.rs defines what kinds of page exist, and templates/ decides how they look.
Step 2: Generate your own migration
Everything from here runs inside the project:
cd ~/projects/myblog
The two page types in src/models.rs are yours, not the CMS's, so their tables aren't in the shipped migrations. ArticlePage stores its body in a table called cms_article_page, and this writes the migration that creates it:
cargo run -- makemigrations
The first build takes a few minutes. Later commands reuse it.
no changes for registry scope
wrote migrations/0017_create_cms_article_page.json (tenant scope)
+ CreateTable("cms_article_page")
+ CreateIndex { name: "cms_article_page_page_id_idx", ... }
Never write those JSON files by hand: makemigrations compares your models against the last snapshot and writes the difference. Run it again any time you change a model, followed by cargo run -- migrate-tenants to apply it.
Step 3: Create the site and start it
cargo run -- quickstart
It asks for a password for the admin user (at least 12 characters), then starts the server. On the first run it does three things before that:
- Creates the registry. rustango-cms is multi-tenant: one registry database knows about your sites, and each site gets its own storage. Even a single-site blog goes through this, so you can add a second site later without restructuring anything.
- Creates the site
demoin its own SQLite file,var/demo.db, reached at the hostnamedemo.localhost. This applies every migration, including the one from Step 2. - Creates the user
admin, so you can sign in.
Run it again later and it skips what already exists and just starts the server; cargo run -- runserver does the same. To do these steps by hand (another site name, another host), see the project's README.
Email from forms needs one more setting. Add a long random
RUSTANGO_SECRET_KEY=...line to.env: it encrypts the secrets the site stores, such as notification tokens. Keep it the same between restarts.
Leave it running and open http://demo.localhost:8080/login. The hostname matters: it's how the server knows which site you're asking for. demo.localhost resolves to your own machine without any /etc/hosts editing.

Sign in as admin with the password you chose. You land on the dashboard:

Everything is empty, which is right — you haven't made anything yet.
Step 4: Create the home page
Click Pages in the sidebar.

Choose Create the first page. The CMS asks which type of page you want:

Only two types are offered, and "Article" isn't one of them. That's src/models.rs talking:
#[async_trait]
impl PageTypeHandler for ArticlePage {
fn allowed_parent_types(&self) -> &'static [&'static str] {
&["HomePage"]
}
// …
}
An article can only live under a home page, so the CMS won't offer it at the top of the tree. You get to state rules like that once and have the admin enforce them everywhere.
Pick Home page, title it My Blog, and set Status to Published. The slug fills itself in from the title.

Click Create page.
The slug is what appears in the URL, so this page will be served at
/my-blog. Clear the slug entirely if you want a page at the site root instead — the help text under the field says so, but the auto-fill will have put something there already.
Step 5: Write an article
Open My Blog, then Add the first child. This time the type picker looks different:

Only Article is offered — the same rule as before, seen from the other side. The banner spells out why: options are filtered by both the parent's allowed_child_types and the candidate's allowed_parent_types.
Pick Article, title it Hello, world, set Status to Published, and this time use Create & keep editing so you stay on the page.
Now the editor shows something the home page didn't: a Body (Markdown) field with a formatting toolbar, and a Hero image picker. Those come from ArticlePage's typed extension table — they're real columns, declared in src/models.rs:
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[rustango(table = "cms_article_page", app = "site")]
pub struct ArticleBody {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(fk = "cms_page", on = "id", index, unique)]
pub page_id: i64,
#[rustango(max_length = 16000)]
pub body_markdown: String,
#[rustango(fk = "cms_media", on = "id")]
pub hero_media_id: Option<i64>,
}
Type some Markdown into the body and press Save & keep editing. The preview pane on the right updates:

The new-page form shows these fields too, so you can fill in the body before the first save. This step used Create & keep editing so you stay in the editor and see the preview.
Step 6: Look at the site
Open http://demo.localhost:8080/my-blog:

The article is listed because templates/home_page.html loops over children, which the CMS puts in the context for every page:
{% for child in children %}
<li><a href="{{ url_prefix }}/{{ child.slug }}">{{ child.title }}</a></li>
{% endfor %}
Follow the link:

That's templates/article_page.html, and it's short enough to read in full:
<article>
<h1>{{ page.title }}</h1>
{% if extension.hero_media_id %}
<img src="{{ rcms_image_url(media_id=extension.hero_media_id, filter='fill-1200x600') }}"
alt="{{ page.title }}">
{% endif %}
{{ extension.body_markdown | markdown | safe }}
</article>
extension is the typed row you saw in the editor. | markdown turns it into HTML; | safe is required after it, because the filter has already sanitized its output and Tera would otherwise escape the tags and show them as text.
How it fits together
Three ideas carry most of the CMS:
The page tree is the site. Every page has a parent, and the URL is built from the slugs along the path. Moving a page in the admin moves its URL, and the children come with it.
A page type is a Rust struct plus a handler. The handler says what the type is called, which template renders it, and where in the tree it's allowed to sit. The CMS reads those answers and builds the admin around them — the type picker, the field list, the validation.
Typed fields live in their own table. Shared things — title, slug, status, publication dates — are columns on cms_page. Fields specific to one type go in a table you own, joined by page_id. Your article bodies are queryable columns, not JSON in a blob.
Everything an editor sees is generated from those three. You didn't write a form, a list view, or a URL route to get here.
Where to go next
- Change
templates/_site.css.html— it's plain CSS inlined into both page templates, with nothing else depending on it. - Add a third page type: copy the
HomePageblock insrc/models.rs, add a force-link line insrc/main.rs, and runmakemigrationsif it has typed fields. - Switch to PostgreSQL when you're ready to deploy. The generated
README.mdhas the same sequence without the SQLite-specific flags, and Run Rustango-CMS in production covers secrets, migrations, the reverse proxy, media and caching.