Part 1: Project Setup

In this tutorial, we'll install the Reinhardt CLI, generate a new project from the pages template, walk through the layout it emits, and run it locally. By the end of the chapter your tree will be logically equivalent to examples/examples-tutorial-basis; the chapters that follow add models, server functions, forms, tests, and admin customization on top of this scaffold.

Verifying Your Installation

Before we begin, let's verify that Rust and Cargo are installed correctly:

rustc --version
cargo --version

You should see version information for both commands. If not, visit rust-lang.org to install Rust.

The tutorial also assumes you have cargo-make installed for the cargo make … task runner:

cargo install cargo-make

Installing Reinhardt Admin CLI

Install the global tool for project generation. While Reinhardt is on a pre-release (-rc.* / -alpha.*), cargo install requires an explicit --version because pre-releases are not selected by default. Once 0.1.0 stable ships, omit --version to pull the latest stable (or keep --version as an opt-in reproducibility pin). The literal below is auto-bumped by release-plz on each release.

cargo install reinhardt-admin-cli --version "0.1.2"

Creating a Project

This tutorial uses the reinhardt-pages template — a WASM client + server functions + shared types layout. Generate the project from that template:

reinhardt-admin startproject polls_project --template pages
cd polls_project

The generated tree matches the reference implementation in examples/examples-tutorial-basis/:

polls_project/
├── Cargo.toml                 # cdylib + rlib; reinhardt server target with "full" + "pages" + "conf" + "commands" + "db-sqlite" + "forms" + "client-router" + "auth-session"; WASM target with "pages" + "client-router"
├── Makefile.toml              # cargo make runserver / migrate / dev / wasm-build-dev / collectstatic / test / …
├── build.rs                   # cfg_aliases: `native` vs `wasm`; also emits `with_reinhardt` cfg after auto-detecting the parent workspace
├── index.html                 # SPA shell — single #root mount + UnoCSS runtime
├── README.md
├── favicon.png
├── migrations/                # Generated by `cargo make makemigrations`
├── scripts/                   # Helper shell scripts for wasm-build-*, run-dev-server, clean-cache
├── static/                    # Project-level static assets (css/, images/, …)
├── settings/
│   ├── base.toml              # always loaded
│   ├── ci.toml                # loaded when REINHARDT_ENV=ci or CI is set
│   └── local.example.toml     # copy to local.toml for local-only overrides
├── src/
│   ├── lib.rs                 # Crate root; declares apps / config / shared / client modules with cfg gates
│   ├── apps.rs                # pub mod polls; pub mod users;
│   ├── config.rs              # pub mod admin/settings/wasm (cfg native); apps / urls compile both targets
│   ├── shared.rs              # pub mod forms (cfg native); pub mod types (both targets)
│   ├── client.rs              # pub mod lib / pages / components / links (gated to cfg wasm at the crate root)
│   ├── bin/
│   │   └── manage.rs          # CLI binary (Django's manage.py equivalent), required-features = ["with-reinhardt"]
│   ├── config/
│   │   ├── settings.rs        # #[settings(core: CoreSettings)] ProjectSettings + SettingsBuilder + profile loading
│   │   ├── apps.rs            # installed_apps! { polls: "polls", users: "users" }
│   │   ├── urls.rs            # #[routes(standalone, client_inventory)] routes() -> UnifiedRouter (server_fn registration, admin mount, session middleware, client-router aggregation)
│   │   ├── wasm.rs            # AppStaticFilesConfig for dist-wasm/, registered via inventory::submit!
│   │   └── admin.rs           # configure_admin() -> AdminSite + register Question/Choice admins
│   ├── shared/
│   │   ├── types.rs           # Shared DTOs (filled in Part 2 onward)
│   │   └── forms.rs           # Server-only Form definitions (filled in Part 4)
│   ├── apps/
│   │   ├── polls/             # Filled in Part 2 onward (models, server_fn, views, urls/, admin, serializers)
│   │   └── users/             # Filled in Part 2 (User model + auth server functions)
│   └── client/                # Filled in Part 3 (lib.rs entry, pages.rs, components/, links.rs)
│       └── …
└── tests/
    ├── integration.rs         # Native integration tests, required-features = ["with-reinhardt"]
    └── wasm/
        └── polls_mock_test.rs # WASM mock tests, required-features = ["msw"] (#![cfg(wasm)]-gated)

Three rules keep this layout predictable:

  1. #[cfg(native)] vs #[cfg(wasm)] — server-only code (models, views, server function bodies, forms, admin) is gated on native; browser-only code (everything in src/client/) is gated on wasm. src/shared/types.rs compiles on both so DTOs stay in sync, and each app's server_fn and urls modules are both targets so the typed client stubs work in the browser.
  2. Server functions are the bridge, and they live per-app — every #[server_fn] lives in src/apps/<app>/server_fn.rs, sitting next to that app's models, views, and admin. There is no top-level src/server_fn/ directory.
  3. Routing is per-app, with a typed urls/ directory modulesrc/apps/<app>/urls.rs declares pub mod server_urls; (#[cfg(native)]) and pub mod client_router; (#[cfg(wasm)]). The framework auto-mounts them via inventory because each function carries #[url_patterns(InstalledApp::<app>, mode = server|client|unified)].

Available cargo make tasks (defined in Makefile.toml):

TaskPurpose
cargo make runserverRun the dev server (depends on migrate); equivalent to cargo run --bin manage runserver --with-pages
cargo make devOne-shot: clean-cachewasm-build-devrun-dev-server (the most common command during a tutorial session)
cargo make makemigrationsGenerate migrations from model changes
cargo make migrateApply migrations to the configured database
cargo make wasm-build-devCompile the WASM bundle (debug) and emit it under dist-wasm/
cargo make wasm-build-releaseCompile + optimise (wasm-opt) the WASM bundle for release
cargo make collectstaticCollect static assets (including dist-wasm/) into staticfiles/
cargo make testcargo nextest run --all-features
cargo make wasm-testRun WASM tests under wasm-pack test --headless --chrome (passes --no-default-features so the manage binary is skipped)
cargo make showurlsPrint every registered URL pattern
cargo make checkProject self-check (Django-style check)
cargo make fmt-check / fmt-fixreinhardt-admin fmt for the page! DSL + rustfmt
cargo make clippy-check / clippy-fixClippy with -D warnings
cargo make quality / quality-fixRun both fmt-* and clippy-*

Note: This tutorial targets the reinhardt-pages architecture end-to-end. If you are instead building a pure JSON backend consumed by an external SPA or mobile client, start with the REST Tutorial.

Understanding the Project Structure

Each generated file has a specific role. Walking top-down:

  • Cargo.toml — declares crate-type = ["cdylib", "rlib"] (cdylib for WASM, rlib for the server binary), default-run = "manage", and an [[bin]] manage whose required-features = ["with-reinhardt"] keep tokio + reinhardt-commands out of the WASM build. It splits dependencies between two [target.'cfg(...)'.dependencies] blocks: the server side enables reinhardt with full + pages + conf + commands + db-sqlite + forms + client-router + auth-session, while the WASM side only enables pages + client-router. Two test targets are declared explicitly: [[test]] name = "integration", required-features = ["with-reinhardt"] (native) and [[test]] name = "polls_mock_test", path = "tests/wasm/polls_mock_test.rs", required-features = ["msw"] (WASM). The crate-local [features] block adds with-reinhardt (native gate), client-router, and msw (forwarded to the facade so #[server_fn] emits MockableServerFn markers).
  • build.rs — uses the cfg_aliases crate to register two custom cfgs: wasm = all(target_family = "wasm", target_os = "unknown") and native = its negation. You will see these throughout the source as #[cfg(wasm)] / #[cfg(native)]. The build script also auto-detects whether the parent directory is the reinhardt-web workspace (subtree development) versus a standalone checkout, and unconditionally emits cargo:rustc-cfg=with_reinhardt in both modes so the integration test target compiles either way.
  • index.html — the SPA shell. It loads UnoCSS from a CDN, defines a #root div that the launcher mounts into, and shows a Loading… spinner while the WASM bundle downloads.
  • settings/ — TOML settings files. base.toml is always loaded; {profile}.toml (resolved from REINHARDT_ENV, or ci when the CI env var is set, or local otherwise) layers on top. local.example.toml is a template — copy it to settings/local.toml for local-only overrides.
  • src/lib.rs — the crate root. It declares pub mod apps;, pub mod config;, pub mod shared;, and #[cfg(wasm)] pub mod client;. Server-only re-exports (async_trait, the reinhardt_apps / reinhardt_core / reinhardt_di::params / reinhardt_http shims) are gated on #[cfg(native)].
  • src/bin/manage.rs — the server-side binary. It sets REINHARDT_SETTINGS_MODULE = "examples_tutorial_basis.config.settings" (rename to your crate name in the generated tree) and calls reinhardt::commands::execute_from_command_line(). The WASM build still needs a main symbol for bin crate-types, so the file also defines an empty fn main() {} under #[cfg(target_arch = "wasm32")].
  • src/config/
    • settings.rs#[settings(core: CoreSettings)] pub struct ProjectSettings; plus a get_settings() function that builds the layered SettingsBuilder (DefaultSourceLowPriorityEnvSource("REINHARDT_")TomlFileSource("base.toml")TomlFileSource("{profile}.toml")). Profile resolution lives in a private profile_name() helper.
    • apps.rsinstalled_apps! { polls: "polls", users: "users" }. The macro generates the InstalledApp enum used as the typed argument to #[url_patterns(InstalledApp::<app>, mode = ...)].
    • urls.rs#[routes(standalone, client_inventory)] pub fn routes() -> UnifiedRouter. Registers every server function via .server(|s| s.server_fn(name::marker)), mounts the admin at /admin/ (plus /static/admin/) via admin_routes_with_di(Arc::new(configure_admin())), and applies the session middleware. The client_inventory flag (#4453) drops the native-only cfg gate from the function body and emits inventory::submit!(ClientRouterRegistration) on wasm32-unknown-unknown; a #[cfg(wasm)] block aggregates each app's client_url_patterns() into the same UnifiedRouter so the SPA route table is complete. Server-side per-app routers are still discovered through their own #[url_patterns(InstalledApp::<app>, mode = server)] registrations, so src/config/urls.rs does not need explicit .mount("/polls/", ...) calls.
    • wasm.rs — an inventory::submit! entry that registers dist-wasm/ as an AppStaticFilesConfig, so cargo make collectstatic discovers the WASM build artifacts and copies them into staticfiles/.
    • admin.rsconfigure_admin() -> AdminSite instantiates the admin site, names it, and registers each app's ModelAdmin implementations (QuestionAdmin, ChoiceAdmin).
  • src/shared/
    • types.rs — DTOs (QuestionInfo, ChoiceInfo, UserInfo, LoginRequest, RegisterRequest, VoteRequest) shared between WASM and server. Validate derives are wrapped in #[cfg_attr(native, derive(Validate))] so the WASM client does not pull in the validator crate.
    • forms.rs#[cfg(native)]-only Form definitions used by the form! macro on the client (forms are constructed server-side and serialized to FormMetadata).
  • src/apps/ — Reinhardt apps. Each app owns its models, server functions, views, URLs, admin, and serializers. We fill these in starting from Part 2.
  • src/client/ — WASM-only UI. lib.rs is the #[wasm_bindgen(start)] entry that calls ClientLauncher::new("#root").register_routes_from_inventory().launch(), picking up every ClientRouterRegistration that the #[routes(standalone, client_inventory)] aggregator in src/config/urls.rs submitted to inventory (PR #4453). pages.rs exposes page factories, components/ contains the page! components, and links.rs wraps ResolvedUrls::resolve_client_url(...) so components never construct URLs by hand. We build this layer in Part 3.

Architecture: WASM + SSR (reinhardt-pages)

This tutorial uses the WASM + SSR architecture with reinhardt-pages, ideal for:

  • Full-stack web applications with an integrated frontend and backend
  • Single Page Applications (SPAs) with server-side rendering
  • Type-safe client-server communication
  • Modern reactive user interfaces

The data flow for one user interaction looks like this:

flowchart LR
    Browser["Browser<br/>(loads WASM)"] -->|Router matches URL| Component["page! component<br/>(src/client/components/…)"]
    Component -->|"calls #[server_fn] stub"| ServerFn["server function<br/>(src/apps/&lt;app&gt;/server_fn.rs)"]
    ServerFn -->|DI: DatabaseConnection,<br/>SessionData, …| Model["Model<br/>(src/apps/&lt;app&gt;/models.rs)"]
    Model -->|via reinhardt-query| DB[(Database)]
    DB --> Model
    Model --> ServerFn
    ServerFn -->|"Result&lt;DTO, ServerFnError&gt;"| Component
    Component -->|"watch { … } re-renders"| Browser

Key characteristics:

  • Unified codebase for frontend and backend
  • Type-safe RPC-style communication via #[server_fn]
  • Client-side reactivity (page! + watch + use_action)
  • Single deployment artifact
  • WASM compilation for the client-side UI

Alternative: RESTful API architecture. If you're building a backend API for separate frontends (React, Vue, mobile apps), see the REST API Tutorial instead.

Configuring settings/base.toml

settings/base.toml holds the always-loaded base layer of your settings. Open it and confirm it contains at least the keys consumed by the [core] and [database] fragments:

[core]
debug = false
secret_key = "CHANGE_THIS_IN_PRODUCTION"
allowed_hosts = []
installed_apps = []
middleware = []
root_urlconf = ""

[core.security]
secure_ssl_redirect = false
secure_hsts_include_subdomains = false
secure_hsts_preload = false
session_cookie_secure = false
csrf_cookie_secure = false
append_slash = true

[database]
engine = "sqlite"
name = "db.sqlite3"

A few things worth knowing as you edit:

  • TomlFileSource::new(path) applies ${VAR} interpolation by default (changed in 0.1.0-rc.27). If you want a literal ${...} to survive the load, opt out per file with .without_interpolation(). The deprecated with_interpolation(bool) setter still works in 0.1.x but will be removed in 0.2.0.
  • The JSON file source (JsonFileSource, auto_source) is deprecated and will be removed in 0.2.0. Stick to TOML.
  • For local-only overrides (e.g., a real DATABASE_URL), copy settings/local.example.toml to settings/local.toml and edit there — it is gitignored.

settings/{profile}.toml is selected dynamically. The resolution order is:

  1. $REINHARDT_ENV if set (e.g., staging, production).
  2. Else ci when $CI is set (matches GitHub Actions / CircleCI).
  3. Else local.

Look in src/config/settings.rs to see this wired up:

use reinhardt::conf::settings::builder::SettingsBuilder;
use reinhardt::conf::settings::profile::Profile;
use reinhardt::conf::settings::sources::{DefaultSource, LowPriorityEnvSource, TomlFileSource};
use reinhardt::core::serde::json;
use reinhardt::settings;
use std::env;
use std::path::PathBuf;

#[settings(core: CoreSettings)]
pub struct ProjectSettings;

fn profile_name() -> String {
	env::var("REINHARDT_ENV").unwrap_or_else(|_| {
		if env::var("CI").is_ok() {
			"ci".to_string()
		} else {
			"local".to_string()
		}
	})
}

fn resolve_settings_dir() -> PathBuf {
	PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("settings")
}

pub fn get_settings() -> ProjectSettings {
	let profile_str = profile_name();
	let settings_dir = resolve_settings_dir();
	let base_dir = env::current_dir().expect("Failed to get current directory");

	SettingsBuilder::new()
		.profile(Profile::parse(&profile_str))
		.add_source(DefaultSource::new().with_value(
			"core.base_dir",
			json::Value::String(base_dir.to_string_lossy().to_string()),
		))
		.add_source(LowPriorityEnvSource::new().with_prefix("REINHARDT_"))
		.add_source(TomlFileSource::new(settings_dir.join("base.toml")))
		.add_source(TomlFileSource::new(
			settings_dir.join(format!("{}.toml", profile_str)),
		))
		.build_composed()
		.expect("Failed to build settings")
}

Need a project-specific setting beyond CoreSettings? You can compose fragments with |, e.g., #[settings(CoreSettings | AuthSettings | DatabaseSettings)] pub struct ProjectSettings;. The basis tutorial keeps things minimal with just CoreSettings; we add DatabaseSettings-driven access in Part 2.

Configuring installed_apps!

src/config/apps.rs declares which Reinhardt apps the framework should discover:

use reinhardt::installed_apps;

installed_apps! {
	polls: "polls",
	users: "users",
}

pub fn get_installed_apps() -> Vec<String> {
	InstalledApp::all_apps()
}

The installed_apps! macro generates the InstalledApp enum that the rest of the codebase uses as the typed argument to #[url_patterns(InstalledApp::<app>, mode = ...)]. Two consequences worth knowing:

  • The left-hand side (polls:) is the enum variant (InstalledApp::polls).
  • The right-hand side ("polls") is the app label that ends up in the URL prefix the framework auto-applies to each app's routers (/polls/ here).

Reinhardt's framework features (auth, sessions, admin, REST, etc.) are not registered through installed_apps!; they are enabled through Cargo feature flags. See the Feature Flags Guide for the full mapping.

Running the Development Server

You have two choices for running the dev server locally.

cargo make runserver — server only

Use this when you have already built the WASM bundle (or do not need it):

cargo make runserver

runserver depends on migrate, so it will create the SQLite file on first run. Internally it executes cargo run --bin manage runserver --with-pages, which starts the server on http://127.0.0.1:8000/ and serves the SPA shell at /.

cargo make dev — WASM build + dev server

This is the most common command during the tutorial. It cleans the WASM cache, rebuilds the bundle in debug mode, and starts the dev server:

cargo make dev

You should see output similar to:

    Compiling polls_project v0.1.0 (/path/to/polls_project)
     Finished dev [unoptimized + debuginfo] target(s) in 2.34s
      Running `target/debug/manage runserver --with-pages`

Reinhardt Development Server
──────────────────────────────────────────────────

  ✓ http://127.0.0.1:8000
  Environment: Debug

Quit the server with CTRL+C

Open your web browser and visit http://127.0.0.1:8000/. You will see the SPA shell from index.html mount the WASM bundle and (once Part 3 lands) display the polls index page.

For a production-grade build, the dev-release family of tasks does the same orchestration but invokes wasm-build-release (with wasm-opt) and collectstatic:

cargo make dev-release

Understanding What Happened

The pages template gave you a five-section project:

  • Server-side modules (src/apps/*/models.rs, views.rs, server_fn.rs, urls/server_urls.rs, admin.rs, serializers.rs) — gated on #[cfg(native)] so they vanish from the WASM build.
  • WASM-side modules (src/client/*, src/apps/*/urls/client_router.rs) — gated on #[cfg(wasm)].
  • Shared modules (src/shared/types.rs, each app's server_fn.rs signatures) — compile for both targets so DTOs and typed RPC stubs stay in sync.
  • Project-level glue (src/config/*, src/bin/manage.rs) — the entry points the framework looks up: routes(), get_installed_apps(), get_settings(), configure_admin(), execute_from_command_line().
  • Task automation (Makefile.toml) — every command you will use throughout the tutorial, from cargo make migrate to cargo make wasm-build-release.

What's Next?

We've created a working pages project with URL routing scaffolding, settings, the management CLI, and the SPA shell. In the next chapter we add the Question, Choice, and User models, wire them through migrations, and prepare the database for the rest of the tutorial.

When you're ready, move on to Part 2: Models and Database.

Summary

In this tutorial you learned:

  • How to install reinhardt-admin-cli and generate a project from the pages template
  • How the src/{lib, apps, config, shared, client, bin} layout maps to native vs WASM code paths via cfg_aliases
  • How settings/base.toml + profile overlays are loaded through SettingsBuilder (with ${VAR} interpolation enabled by default)
  • How installed_apps! exposes InstalledApp::polls / InstalledApp::users to the typed #[url_patterns] attribute
  • How cargo make runserver and cargo make dev differ — and which cargo make tasks ship in Makefile.toml
  • The reinhardt-pages data flow: page component → #[server_fn] stub → model → database → DTO → reactive re-render